Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/27259-validate-seed-record-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/cli': minor
'@objectstack/objectql': minor
---

fix(cli)!: `os validate` and `os build` refuse a seed record value the seed load would refuse, with the load's own code and sentence, located at the record's field (#27259)

Clause-②: no (narrowing) — `os validate` and `os build` refuse a stack whose seed record carries a value the record validator refuses at seed load (a currency written `{ value, currency }`, an unreadable number, a value outside a field's options, an unreadable date), which both commands passed before. What the runtime stores and refuses does not move: every row refused here was already refused when the seed loaded. Nothing is widened.
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped. The refusal moves earlier, from the seed load to the authoring commands, with the load's own wording: a seed row it names was already dropped at load, so there is nothing for a migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing at `os validate` and `os build` (`@objectstack/cli`); one new export on `@objectstack/objectql`.

- **Before.** A seed record such as `budget: { value: 10, currency: 'USD' }` on a currency field passed `os validate` with "Validation passed" (exit 0) and `os build` emitted the artifact. The row was refused only when the seed loaded, by the record validator: `Budget is a currency amount: write it as a bare number …`, one dropped row in the boot log.
- **Now.** Both commands judge every seed record value with the judge the load runs, and refuse with that judge's code and sentence, located at the value: `data[0].records[4].budget: Budget is a currency amount: write it as a bare number … (invalid_number)`, exit 1. `--json` carries each issue as `{ location, object, field, code, message }`.
- **One judge.** The verdict is `validateInsertValues` (`@objectstack/objectql`, new): the insert write's own normalizations (a blank on a typed field is `null`, a numeric string is its number, a scalar on a multi-value field is wrapped) and then the same record-validator call the insert path makes. The field map is the runtime registry's, built from the stack's objects and picklists. So a value the load accepts after a rewrite (`'10'` on a number field) still passes.
- **Left to the load.** `required` on a missing value (a default or a hook may fill it; an explicit `[]` on a required multi-value field is refused `required` here, as at load), a field the object does not declare (`defineSeed` already refuses it), and every value the load rewrites or a deployment decides: an expression envelope (the `cel` template), a `seedFile(path)`, a reference, file or structured-JSON field, a field bound to a picklist another package declares, and a dataset whose object another package declares.
- **Known limits.** A `beforeInsert` hook is not run at authoring time, so a value a hook would rewrite into an accepted shape is refused here though the load stores it. A dataset whose object is declared only under `manifest.objects`, outside `packages[]`, is not judged.
- **Who sees it.** A stack shipping a seed row the load already drops. The four example apps in this repository (`app-todo`, `app-crm`, `app-showcase`, `app-multi-package`) carry none and pass unchanged.

**The remedy.** Write the value in the shape the message names, for a currency a bare number such as `10`, with the currency in the field's `currencyConfig`.
17 changes: 17 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@objectstack/spec';
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
import { collectSeedFiles, placeSeedFiles } from '../utils/bundle-seed-files.js';
import { judgeSeedRecordValues } from '../utils/seed-record-values.js';
import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js';
import { isExtractionRefusal, lowerCallables } from '../utils/lower-callables.js';
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
Expand Down Expand Up @@ -1084,6 +1085,22 @@ export default class Compile extends Command {
finalBundle.packages = placed.packages;
}

// 4a-0. [#27259] Seed record VALUES, judged by the insert write's own
// value verdict — the gate `os validate` runs at its step 3d-bis. A
// value the seed load refuses fails the build here, with the load's
// code and sentence, rather than a row at boot.
const seedValueIssues = judgeSeedRecordValues(finalBundle);
if (seedValueIssues.length > 0) {
if (flags.json) {
await emitJson({ success: false, error: 'seed record values refused', issues: seedValueIssues, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
this.exit(1);
}
console.log('');
printError(`Seed record values the seed load refuses (${seedValueIssues.length} issue${seedValueIssues.length > 1 ? 's' : ''})`);
for (const issue of seedValueIssues) console.log(chalk.red(` ✗ ${issue.location}: ${issue.message} (${issue.code})`));
this.exit(1);
}

// 4a. [#25779] The files seed records name with `seedFile(path)`, read
// relative to the directory holding the config — the gate
// `os validate` runs too — then written into their datasets'
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manif
import { preflightDeclaredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
import { collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
import { collectSeedFiles } from '../utils/bundle-seed-files.js';
import { judgeSeedRecordValues } from '../utils/seed-record-values.js';
import {
printHeader,
printKV,
Expand Down Expand Up @@ -804,6 +805,31 @@ export default class Validate extends Command {
this.exit(1);
}

// 3d-bis. [#27259] Seed record VALUES — each value a seed record writes,
// judged by the insert write's own value verdict
// (`validateInsertValues`, `@objectstack/objectql`), so a value the
// seed load refuses (a currency written `{ value, currency }`) is
// refused here with the load's code and sentence. ⛔ One judge: the
// helper owns only the walk (`utils/seed-record-values.ts`). `os build`
// runs the same call before it bundles the seed files.
const seedValueIssues = judgeSeedRecordValues(result.data as Record<string, unknown>);
if (seedValueIssues.length > 0) {
if (flags.json) {
await emitJson({
valid: false,
errors: seedValueIssues,
warnings: warningsSoFar(),
conversions: conversionNotices,
duration: timer.elapsed(),
});
this.exit(1);
}
console.log('');
printError(`Seed record values the seed load refuses (${seedValueIssues.length} issue${seedValueIssues.length > 1 ? 's' : ''})`);
for (const issue of seedValueIssues) console.log(chalk.red(` ✗ ${issue.location}: ${issue.message} (${issue.code})`));
this.exit(1);
}

// 3e. ADR-0141 D2, author side — every `manifest.dependencies` range of
// the packages one install act would deliver, judged by the install's
// own predicate (`utils/dependency-range-check.ts`). An `error` is
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/utils/authoring-filter-judge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Arra
* The stack's object declarations, from either spelling a stack carries: the
* array, or the name-keyed map (whose key is the name when the entry omits it).
*/
function objectsOf(value: unknown): AnyRec[] {
export function objectsOf(value: unknown): AnyRec[] {
if (Array.isArray(value)) return value.filter(isRec);
if (!isRec(value)) return [];
return Object.entries(value)
Expand All @@ -66,7 +66,7 @@ function objectsOf(value: unknown): AnyRec[] {
}

/** A copy registration may stamp without touching the stack the other rules read. */
function registrationCopy(obj: AnyRec): AnyRec {
export function registrationCopy(obj: AnyRec): AnyRec {
if (!isRec(obj.fields)) return { ...obj };
const fields: AnyRec = {};
for (const [key, def] of Object.entries(obj.fields)) fields[key] = isRec(def) ? { ...def } : def;
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/utils/bundle-seed-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const SEED_FILE_TYPES: Readonly<Record<string, string>> = {
};

/** One dataset of the artifact, as far as this pass reads it. */
interface DatasetLike {
export interface DatasetLike {
records?: unknown;
files?: Record<string, SeedFileContent>;
}
Expand All @@ -91,7 +91,7 @@ interface DatasetLike {
* position, off the objects themselves, because {@link placeSeedFiles} writes
* into these very datasets — the ones the artifact serializes.
*/
function datasetsOf(stack: Record<string, unknown>): Array<{ dataset: DatasetLike; location: string }> {
export function datasetsOf(stack: Record<string, unknown>): Array<{ dataset: DatasetLike; location: string }> {
const found: Array<{ dataset: DatasetLike; location: string }> = [];
const seen = new Set<unknown>();
const take = (list: unknown, location: string): void => {
Expand Down
138 changes: 138 additions & 0 deletions packages/cli/src/utils/seed-record-values.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { cel } from '@objectstack/spec';
import { seedFile } from '@objectstack/spec/data';
import { judgeSeedRecordValues } from './seed-record-values.js';

/**
* [#27259] `os validate` and `os build` judge each seed record value with the
* insert write's value verdict (`validateInsertValues`), so a value the seed
* load refuses is refused at authoring time, with the load's code and
* sentence, located at the record's field. The load-side half of that claim
* (the same code and sentence from a real seed load) is
* `test/seed-record-values-load-parity.test.ts`.
*/

const LESSON_START = 'Budget is a currency amount: write it as a bare number';

const PROJECT = {
name: 'proj_project',
label: 'Project',
fields: {
name: { type: 'text', label: 'Name', required: true },
budget: { type: 'currency', label: 'Budget' },
hours: { type: 'number', label: 'Hours', min: 0 },
stage: { type: 'select', label: 'Stage', options: [{ label: 'Open', value: 'open' }, { label: 'Done', value: 'done' }] },
tags: { type: 'multiselect', label: 'Tags', options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }] },
industry: { type: 'select', label: 'Industry', picklist: 'industry' },
sector: { type: 'select', label: 'Sector', picklist: 'sector_elsewhere' },
owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' },
due: { type: 'date', label: 'Due' },
brief: { type: 'file', label: 'Brief' },
},
};

const INDUSTRY = { name: 'industry', label: 'Industry', options: [{ label: 'Tech', value: 'tech' }] };

function stackWith(records: Record<string, unknown>[], extra: Record<string, unknown> = {}) {
return {
objects: [PROJECT],
picklists: [INDUSTRY],
data: [{ object: 'proj_project', records }],
...extra,
};
}

describe('[#27259] judgeSeedRecordValues — the seed load\'s value verdict at authoring time', () => {
it('refuses a currency written as an object, with the currency lesson, located at the record\'s field', () => {
const issues = judgeSeedRecordValues(stackWith([
{ name: 'Alpha', budget: 10 },
{ name: 'Beta', budget: { value: 10, currency: 'USD' } },
]));
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({
location: 'data[0].records[1].budget',
object: 'proj_project',
field: 'budget',
code: 'invalid_number',
});
expect(issues[0].message.startsWith(LESSON_START)).toBe(true);
expect(issues[0].message).toContain('currencyConfig');
});

it('passes a bare number on the currency field', () => {
expect(judgeSeedRecordValues(stackWith([{ name: 'Alpha', budget: 10 }]))).toEqual([]);
});

it('passes a value the load accepts after its rewrite: a numeric string, a blank, a scalar on a multi-value field', () => {
expect(judgeSeedRecordValues(stackWith([
{ name: 'Alpha', budget: '10', hours: '2.5', due: '', tags: 'alpha' },
]))).toEqual([]);
});

it('refuses what the load refuses about a value: an unreadable number, a bound, an option, a date', () => {
const issues = judgeSeedRecordValues(stackWith([
{ name: 'Alpha', hours: 'abc', stage: 'archived', due: 'yesterday' },
{ name: 'Beta', hours: -1, tags: ['alpha', 'zzz'] },
]));
expect(issues.map((i) => [i.location, i.code])).toEqual([
['data[0].records[0].hours', 'invalid_number'],
['data[0].records[0].stage', 'invalid_option'],
['data[0].records[0].due', 'invalid_date'],
['data[0].records[1].hours', 'min_value'],
['data[0].records[1].tags', 'invalid_option'],
]);
});

it('judges a picklist-bound field against the options the stack\'s picklist resolves', () => {
expect(judgeSeedRecordValues(stackWith([{ name: 'Alpha', industry: 'tech' }]))).toEqual([]);
const issues = judgeSeedRecordValues(stackWith([{ name: 'Alpha', industry: 'retail' }]));
expect(issues.map((i) => [i.field, i.code])).toEqual([['industry', 'invalid_option']]);
});

it('leaves to the load what it rewrites or a deployment decides: an expression, a seed file, a reference, a file, a list declared elsewhere', () => {
expect(judgeSeedRecordValues(stackWith([
{
name: 'Alpha',
hours: cel`1 + 1`,
due: cel`daysFromNow(3)`,
brief: seedFile('fixtures/brief.pdf'),
owner: 'admin@example.com',
sector: 'anything',
},
]))).toEqual([]);
});

it('does not judge `required`: a row missing a required value is the load\'s (defaults and hooks may fill it)', () => {
expect(judgeSeedRecordValues(stackWith([{ budget: 5 }, { name: null, budget: 6 }]))).toEqual([]);
});

it('CONTROL: a field the object does not declare gets no answer from this check', () => {
expect(judgeSeedRecordValues(stackWith([{ name: 'Alpha', no_such_field: { value: 10 } }]))).toEqual([]);
});

it('CONTROL: a dataset for an object the stack does not declare is not judged', () => {
expect(judgeSeedRecordValues({
objects: [PROJECT],
data: [{ object: 'sys_business_unit', records: [{ name: 'HQ', budget: { value: 1 } }] }],
})).toEqual([]);
});

it('judges a package body\'s datasets against every package\'s objects, located where they sit', () => {
const issues = judgeSeedRecordValues({
packages: [
{ manifest: { id: 'com.example.core', name: 'core', version: '1.0.0', type: 'app', objects: [PROJECT] } },
{ manifest: { id: 'com.example.seed', name: 'seed', version: '1.0.0', type: 'app', data: [{ object: 'proj_project', records: [{ name: 'A', budget: { value: 1, currency: 'EUR' } }] }] } },
],
});
expect(issues.map((i) => [i.location, i.code])).toEqual([['packages[1].manifest.data[0].records[0].budget', 'invalid_number']]);
});

it('reads only: the stack it is handed is unchanged', () => {
const stack = stackWith([{ name: 'Alpha', budget: '10', tags: 'alpha', due: '' }]);
const before = JSON.stringify(stack);
judgeSeedRecordValues(stack);
expect(JSON.stringify(stack)).toBe(before);
});
});
Loading
Loading