Skip to content

Commit 99aeee6

Browse files
fix(cli)!: os validate and os build refuse a seed record value the seed load would refuse, in the load's words (#27600)
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBKxmDhctefSL1Rt755zqeNc)_ Fixes #27259 Claim: 6496966334 (amended 6499269644) Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none edited. No skill teaches that `os validate` passes a seed value the load refuses, and none teaches a `{ value, currency }` seed. Governed hits that list what `os validate` checks stay true as non-exhaustive lists: `skills/objectstack-platform/references/operations.md:13`, `skills/objectstack-data/SKILL.md:453`, `skills/objectstack-ui/SKILL.md:284`. The seed teaching (`skills/objectstack-data/references/seeds.md`, `skills/objectstack-formula/SKILL.md:270` on `cel` seed values) stays true: an expression value is left to the load. Named, not edited. ADR-0087: `not-required (no-migration-prescription)`, in the changeset. No key of any schema moves; the refusal moves earlier, from the seed load to the authoring commands, in the load's own words, and every row it names was already dropped at load. **`Metadata-protocol: narrowed`, per the claim amendment.** A seed value `os validate` and `os build` newly refuse is a value refused at the authoring door, so the kind is `narrowed` and both packages the diff moves take `minor`. The measurement beside it: what the runtime stores does not move. The record validator refused these values at seed load before this PR and refuses the same values after it; the refusal only moves earlier. The changeset carries the BREAKING banner. ## 维护者速读 - **改了什么:** `os validate` 和 `os build` 现在用种子加载时同一个记录校验器检查每条种子记录的字段值;加载会拒收的值(比如货币字段写成 `{ value: 10, currency: 'USD' }`)在校验时就报错,错误码和文案与加载时一字不差,并定位到 `data[0].records[4].budget`。 - **为什么改:** 以前作者自己的检查说"通过"(exit 0),这条种子要等到启动加载时才被丢弃,只在启动日志里留一行错误。 - **风险与回滚:** 收窄 `os validate`/`os build` 的接受集;运行时接受与拒收的集合不变(这些行加载时本来就被拒)。仓内四个示例应用 873 个种子值全部照常通过。回滚 = revert 本 PR。 - **先看:** `packages/cli/src/utils/seed-record-values.ts`(哪些值交给判定、哪些留给加载)、`packages/objectql/src/validation/record-validator.ts` 的 `validateInsertValues`(插入路径的同一套归一化与校验调用)、`packages/cli/test/seed-record-values-load-parity.test.ts`(与真实种子加载逐行对拍)。 - **两分钟验证:** `pnpm --filter @objectstack/cli exec vitest run test/seed-record-values-load-parity.test.ts` → 1 个用例绿:校验时拒收的行号 = 真实加载拒收的行号(0、3、4、5、6),且加载日志里的句子以校验给出的句子结尾。 - **需要你做的:** 无,席位落地。 ## What changes **Before, measured on `origin/main` `ad3a640e`.** A scratch copy of `examples/app-todo` with `budget: Field.currency({ label: 'Budget' })` on `todo_task` and one seed row `budget: { value: 10, currency: 'USD' }`: ```text $ npx objectstack validate → "✓ Validation passed (277ms)", exit 0 $ npx objectstack dev --fresh → ERROR [SeedLoader] Failed to write todo_task record #4 (subject=Fix Server bug): Budget is a currency amount: write it as a bare number (ADR-0104), such as 10, not an object such as { value, currency }. A currency field declares its currency in currencyConfig: the fixed mode's defaultCurrency, or under the dynamic mode (the default) the tenant default currency "Seeds: com.example.todo 7 ok / 1 error" ``` **After, same copy, head `d914d366` and later.** ```text $ npx objectstack validate → exit 1 ✗ Seed record values the seed load refuses (1 issue) ✗ data[0].records[4].budget: Budget is a currency amount: write it as a bare number (ADR-0104), such as 10, not an object such as { value, currency }. … the tenant default currency (invalid_number) $ npx objectstack build → exit 1, the same two lines budget: 10 → "✓ Validation passed", exit 0 budget: '10' → "✓ Validation passed", exit 0 (the load writes the number 10) budget: -5 → "✓ Validation passed", exit 0 budget: 'abc' → exit 1, "data[0].records[4].budget: Budget must be a number (invalid_number)" ``` - **`@objectstack/objectql`: `validateInsertValues(objectSchema, row, options?)`, new export.** The insert write's verdict on the values a row supplies: the insert door's two rewrites (`normalizeBlankTypedValues`, `normalizeNumericStringValues`), the multi-value wrap (`normalizeMultiValueFields`, scope `'include'`), then the same `validateRecordInScope(…, 'insert', 'include', …)` call `ObjectQL.insert()` makes. It judges only the declared fields the row supplies with a non-missing value, so `required` (which a default or a hook may still satisfy) and undeclared names are left to the load. ⛔ One judge: nothing in it re-states a value rule; it composes the functions the insert path already calls, in the insert path's order. - **`@objectstack/cli`: `judgeSeedRecordValues(stack)` (`utils/seed-record-values.ts`).** Owns only the WALK. The field map is the runtime registry's: a driverless `ObjectQL` (the shape `authoring-filter-judge.ts` measured) holding the stack's objects, picklists and picklist extensions, folded across packages (`authoringRuleUnionStack`). Datasets are read where they sit (`datasetsOf`, now exported from `bundle-seed-files.ts`): top level, `manifest.data`, each `packages[i].manifest.data`. Each issue is `{ location, object, field, code, message }`, with the validator's code and sentence. - **What the walk does not hand to the judge**, each a value the load rewrites before the write or one a deployment decides: a value holding an expression envelope (the load evaluates it) or a `seedFile(path)` (the load stores the file and writes its id; `collectSeedFiles` judges the path); a reference, file or structured-JSON field (the load resolves a reference's natural key to an id, and the ADR-0104 shape check refuses only on a deployment whose migration evidence says so); a field bound to a picklist the stack does not declare (`judgePicklistReferences` reports it); a dataset whose object the stack does not declare or the registry refuses. - **Wiring.** `os validate` step 3d-bis, right after the seed-file gate; `os build` step 4a-0, right before it. Both exit 1 with the issues; `--json` carries them as `errors` (validate) and `issues` (build), the shapes each command's seed-file exit already uses. `test/validate-build-gate-parity.test.ts` holds the call in `SHARED_NON_REGISTRY_GATES`. - **Two exports made visible inside `packages/cli`:** `objectsOf` / `registrationCopy` (`authoring-filter-judge.ts`) and `datasetsOf` / `DatasetLike` (`bundle-seed-files.ts`), so the registry build and the dataset walk are each written once. No published CLI surface changes. ## Shipped-seed reading (the narrowing) Every seed this repository ships, run through the new check on head `98d28ec0` (`os validate` in each app, plus a read-only probe counting the values the walk hands the judge): | App | Datasets | Records | Values | Judged | Left to load: expression or seed file | Left: reference, file, structured | Object not in stack | Newly refused | |:---|---:|---:|---:|---:|---:|---:|---:|---:| | `examples/app-todo` | 1 | 8 | 39 | 32 | 7 | 0 | 0 | **0** | | `examples/app-crm` | 5 | 28 | 185 | 137 | 17 | 31 | 0 | **0** | | `examples/app-showcase` | 19 | 132 | 969 | 704 | 101 | 135 | 29 (`sys_business_unit`) | **0** | | `examples/app-multi-package` | 0 | 0 | 0 | 0 | 0 | 0 | 0 | **0** | All four apps answer `✓ Validation passed`, exit 0, as before. The showcase's `field-zoo` dataset covers every field type and passes. No platform object, plugin or service in `packages/**` ships seed datasets (`git grep` for `defineSeed(` and `records: [` outside tests: none). Dogfood boots these examples and is green on `603ecf33`. HotCRM fixtures are not in this repository and were not read. ## Tests - `packages/objectql/src/validation/record-validator.test.ts`, a new `[#27259]` block, 7 cases: a currency object is refused with exactly the fields `validateRecord(…, 'insert')` gives (`invalid_number`, the lesson, the label); a bare number passes; a numeric string, a blank and a scalar on a multi-value field pass; an omitted or null required value is not judged (CONTROL: `validateRecord` on insert refuses the omission); an undeclared name gets no answer (CONTROL); a readonly value's shape is judged, as on the insert path; the row is never mutated. - `packages/cli/src/utils/seed-record-values.test.ts`, 11 cases: the currency object refused at `data[0].records[1].budget` with the lesson; a bare number passes; rewritten values pass; an unreadable number, an unknown option, an unreadable date, a value below `min` and a bad multi-value element are refused with their codes; a picklist-bound field is judged against the stack's picklist; an expression, a seed file, a reference, a file and a field on a picklist declared elsewhere are left to the load; `required` is not judged; CONTROL: an undeclared field and a dataset for an object outside the stack get no answer; a package body's dataset is located at `packages[1].manifest.data[0].records[0].budget`; the stack is not mutated. - `packages/cli/test/seed-record-values-load-parity.test.ts` (integration tier by its `new ObjectQL(` signal), 1 case: seven rows through the check and through a real `SeedLoaderService` load on an `ObjectQL` engine over SQLite `:memory:`. The rows the check refuses are exactly the rows the load refuses (0, 3, 4, 5, 6), each load message ends with the check's sentence, and the two rows it passes are written. - `packages/cli/test/validate-build-gate-parity.test.ts`: `judgeSeedRecordValues` joins `SHARED_NON_REGISTRY_GATES`. `packages/cli/test/normalized-call-sites.test.ts`: a `resolved` row for the walk's folded read. ## Acceptance notes - The CONTROL with an unknown field: on a `defineSeed` dataset, `defineSeed` itself already refuses an undeclared field when the config loads (`✗ defineSeed('todo_task'): unknown field(s) in records — bogus_field.`, exit 1). That answer is byte-identical before and after (diffed, timing lines aside). On a plain dataset the new check gives no answer for the name. - A seed dataset for a platform object (`sys_business_unit` in the showcase) is not judged: the stack does not declare the object, so `os validate` has no field map for it. The runtime judges it at load as before. - A readonly field's value is judged for shape on the insert path; the check judges it the same way. The `required` check on a missing value, validation rules, reference resolution and permissions stay the load's. A supplied empty array on a required multi-value field is not missing: the check refuses it `required`, as the load's insert path does. - Known limit, hooks: a `beforeInsert` hook runs on a seed write before the record validator, and the authoring check runs no hooks. A stack whose hook rewrites a seed value into an accepted shape loads clean but is refused by `os validate`. The walk's header names this. - Known limit, coverage: a dataset whose object is declared only under `manifest.objects`, outside `packages[]`, has no field map in the folded stack and is not judged (no false refusal). The walk's header names this. ## Verification <details><summary>Package suites</summary> - `pnpm --filter @objectstack/objectql test` on `98d28ec0` (after it, objectql source moved only in a docblock, `a44c5261`) exit 0: Test Files 485 passed (485), Tests 9124 passed (9124). `pnpm --filter @objectstack/objectql typecheck` exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit` on `603ecf33`: VERDICT command-exit 0, Test Files 350 passed (350), Tests 5066 passed (5066). (On `98d28ec0` it caught `normalized-call-sites.test.ts`: the walk's folded read had no row; added in `603ecf33`.) `pnpm --filter @objectstack/cli typecheck` exit 0. The parity file (integration tier): 1 passed on `603ecf33`. - The integration tier beyond the new parity file is declared to CI. </details> <details><summary>Ablation (each through scripts/ablation-replace.mjs, restored blob == HEAD, git diff HEAD empty)</summary> - `packages/cli/src/utils/seed-record-values.ts`: the `validateInsertValues(...)` call replaced by `void row;` (anchor 1 → 0, blob 1e84757 → 1748ccd5): `seed-record-values.test.ts` 4 failed, 7 passed. Restored. - `packages/objectql/src/validation/record-validator.ts`: the `normalizeMultiValueFields(objectSchema, row, 'include');` line deleted (anchor 1 → 0, blob dc438b6 → 16c92c87): the `[#27259]` block 2 failed, 5 passed (the rewrite case and the no-mutation case). Restored. </details> <details><summary>Dogfood (Clause-② narrowing arm), head 603ecf3</summary> `pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test` under the lock: VERDICT command-exit 0, Test Files 350 passed | 3 skipped (353), Tests 2817 passed | 20 skipped (2837). </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 65 derived families, the same list on `603ecf33` and on `8e7f125e`. All 65 run serially as `CMD :: exit N` on `603ecf33`; the 9 changeset and closing-keyword families re-run on `8e7f125e` after the changeset's kind changed (all exit 0). `--ran`: 65 accounted, 64 run, 1 NOT-MEASURED, 0 UNRUN. - 64 exit 0. - `pnpm check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET (needs a full build: no `dist` for `embedder-openai`, `knowledge-ragflow`, `service-cluster-redis`); CI runs it. - `GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event` with this body: exit 0 (`Clause-②: no (narrowing)`, `Metadata-protocol: narrowed`, minor). - A first run on `98d28ec0` caught `check:driver-memory-census` exit 1: the parity test bound the frozen `@objectstack/driver-memory`. It now loads through `@objectstack/driver-sql` on SQLite `:memory:`, and the gate is green on `603ecf33`. - PR check-runs: in progress at the time of writing. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufziV1z63WH7moz8f5TS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 54c2496 commit 99aeee6

13 files changed

Lines changed: 601 additions & 6 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
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)
7+
8+
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.
9+
Metadata-protocol: narrowed
10+
11+
<!-- 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. -->
12+
13+
**BREAKING** accept-set narrowing at `os validate` and `os build` (`@objectstack/cli`); one new export on `@objectstack/objectql`.
14+
15+
- **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.
16+
- **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 }`.
17+
- **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.
18+
- **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.
19+
- **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.
20+
- **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.
21+
22+
**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`.

‎packages/cli/src/commands/compile.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@objectstack/spec';
1717
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
1818
import { collectSeedFiles, placeSeedFiles } from '../utils/bundle-seed-files.js';
19+
import { judgeSeedRecordValues } from '../utils/seed-record-values.js';
1920
import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js';
2021
import { isExtractionRefusal, lowerCallables } from '../utils/lower-callables.js';
2122
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
@@ -1084,6 +1085,22 @@ export default class Compile extends Command {
10841085
finalBundle.packages = placed.packages;
10851086
}
10861087

1088+
// 4a-0. [#27259] Seed record VALUES, judged by the insert write's own
1089+
// value verdict — the gate `os validate` runs at its step 3d-bis. A
1090+
// value the seed load refuses fails the build here, with the load's
1091+
// code and sentence, rather than a row at boot.
1092+
const seedValueIssues = judgeSeedRecordValues(finalBundle);
1093+
if (seedValueIssues.length > 0) {
1094+
if (flags.json) {
1095+
await emitJson({ success: false, error: 'seed record values refused', issues: seedValueIssues, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
1096+
this.exit(1);
1097+
}
1098+
console.log('');
1099+
printError(`Seed record values the seed load refuses (${seedValueIssues.length} issue${seedValueIssues.length > 1 ? 's' : ''})`);
1100+
for (const issue of seedValueIssues) console.log(chalk.red(` ✗ ${issue.location}: ${issue.message} (${issue.code})`));
1101+
this.exit(1);
1102+
}
1103+
10871104
// 4a. [#25779] The files seed records name with `seedFile(path)`, read
10881105
// relative to the directory holding the config — the gate
10891106
// `os validate` runs too — then written into their datasets'

‎packages/cli/src/commands/validate.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manif
2626
import { preflightDeclaredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
2727
import { collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
2828
import { collectSeedFiles } from '../utils/bundle-seed-files.js';
29+
import { judgeSeedRecordValues } from '../utils/seed-record-values.js';
2930
import {
3031
printHeader,
3132
printKV,
@@ -804,6 +805,31 @@ export default class Validate extends Command {
804805
this.exit(1);
805806
}
806807

808+
// 3d-bis. [#27259] Seed record VALUES — each value a seed record writes,
809+
// judged by the insert write's own value verdict
810+
// (`validateInsertValues`, `@objectstack/objectql`), so a value the
811+
// seed load refuses (a currency written `{ value, currency }`) is
812+
// refused here with the load's code and sentence. ⛔ One judge: the
813+
// helper owns only the walk (`utils/seed-record-values.ts`). `os build`
814+
// runs the same call before it bundles the seed files.
815+
const seedValueIssues = judgeSeedRecordValues(result.data as Record<string, unknown>);
816+
if (seedValueIssues.length > 0) {
817+
if (flags.json) {
818+
await emitJson({
819+
valid: false,
820+
errors: seedValueIssues,
821+
warnings: warningsSoFar(),
822+
conversions: conversionNotices,
823+
duration: timer.elapsed(),
824+
});
825+
this.exit(1);
826+
}
827+
console.log('');
828+
printError(`Seed record values the seed load refuses (${seedValueIssues.length} issue${seedValueIssues.length > 1 ? 's' : ''})`);
829+
for (const issue of seedValueIssues) console.log(chalk.red(` ✗ ${issue.location}: ${issue.message} (${issue.code})`));
830+
this.exit(1);
831+
}
832+
807833
// 3e. ADR-0141 D2, author side — every `manifest.dependencies` range of
808834
// the packages one install act would deliver, judged by the install's
809835
// own predicate (`utils/dependency-range-check.ts`). An `error` is

‎packages/cli/src/utils/authoring-filter-judge.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Arra
5757
* The stack's object declarations, from either spelling a stack carries: the
5858
* array, or the name-keyed map (whose key is the name when the entry omits it).
5959
*/
60-
function objectsOf(value: unknown): AnyRec[] {
60+
export function objectsOf(value: unknown): AnyRec[] {
6161
if (Array.isArray(value)) return value.filter(isRec);
6262
if (!isRec(value)) return [];
6363
return Object.entries(value)
@@ -66,7 +66,7 @@ function objectsOf(value: unknown): AnyRec[] {
6666
}
6767

6868
/** A copy registration may stamp without touching the stack the other rules read. */
69-
function registrationCopy(obj: AnyRec): AnyRec {
69+
export function registrationCopy(obj: AnyRec): AnyRec {
7070
if (!isRec(obj.fields)) return { ...obj };
7171
const fields: AnyRec = {};
7272
for (const [key, def] of Object.entries(obj.fields)) fields[key] = isRec(def) ? { ...def } : def;

‎packages/cli/src/utils/bundle-seed-files.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ const SEED_FILE_TYPES: Readonly<Record<string, string>> = {
8080
};
8181

8282
/** One dataset of the artifact, as far as this pass reads it. */
83-
interface DatasetLike {
83+
export interface DatasetLike {
8484
records?: unknown;
8585
files?: Record<string, SeedFileContent>;
8686
}
@@ -91,7 +91,7 @@ interface DatasetLike {
9191
* position, off the objects themselves, because {@link placeSeedFiles} writes
9292
* into these very datasets — the ones the artifact serializes.
9393
*/
94-
function datasetsOf(stack: Record<string, unknown>): Array<{ dataset: DatasetLike; location: string }> {
94+
export function datasetsOf(stack: Record<string, unknown>): Array<{ dataset: DatasetLike; location: string }> {
9595
const found: Array<{ dataset: DatasetLike; location: string }> = [];
9696
const seen = new Set<unknown>();
9797
const take = (list: unknown, location: string): void => {
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { cel } from '@objectstack/spec';
5+
import { seedFile } from '@objectstack/spec/data';
6+
import { judgeSeedRecordValues } from './seed-record-values.js';
7+
8+
/**
9+
* [#27259] `os validate` and `os build` judge each seed record value with the
10+
* insert write's value verdict (`validateInsertValues`), so a value the seed
11+
* load refuses is refused at authoring time, with the load's code and
12+
* sentence, located at the record's field. The load-side half of that claim
13+
* (the same code and sentence from a real seed load) is
14+
* `test/seed-record-values-load-parity.test.ts`.
15+
*/
16+
17+
const LESSON_START = 'Budget is a currency amount: write it as a bare number';
18+
19+
const PROJECT = {
20+
name: 'proj_project',
21+
label: 'Project',
22+
fields: {
23+
name: { type: 'text', label: 'Name', required: true },
24+
budget: { type: 'currency', label: 'Budget' },
25+
hours: { type: 'number', label: 'Hours', min: 0 },
26+
stage: { type: 'select', label: 'Stage', options: [{ label: 'Open', value: 'open' }, { label: 'Done', value: 'done' }] },
27+
tags: { type: 'multiselect', label: 'Tags', options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }] },
28+
industry: { type: 'select', label: 'Industry', picklist: 'industry' },
29+
sector: { type: 'select', label: 'Sector', picklist: 'sector_elsewhere' },
30+
owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' },
31+
due: { type: 'date', label: 'Due' },
32+
brief: { type: 'file', label: 'Brief' },
33+
},
34+
};
35+
36+
const INDUSTRY = { name: 'industry', label: 'Industry', options: [{ label: 'Tech', value: 'tech' }] };
37+
38+
function stackWith(records: Record<string, unknown>[], extra: Record<string, unknown> = {}) {
39+
return {
40+
objects: [PROJECT],
41+
picklists: [INDUSTRY],
42+
data: [{ object: 'proj_project', records }],
43+
...extra,
44+
};
45+
}
46+
47+
describe('[#27259] judgeSeedRecordValues — the seed load\'s value verdict at authoring time', () => {
48+
it('refuses a currency written as an object, with the currency lesson, located at the record\'s field', () => {
49+
const issues = judgeSeedRecordValues(stackWith([
50+
{ name: 'Alpha', budget: 10 },
51+
{ name: 'Beta', budget: { value: 10, currency: 'USD' } },
52+
]));
53+
expect(issues).toHaveLength(1);
54+
expect(issues[0]).toMatchObject({
55+
location: 'data[0].records[1].budget',
56+
object: 'proj_project',
57+
field: 'budget',
58+
code: 'invalid_number',
59+
});
60+
expect(issues[0].message.startsWith(LESSON_START)).toBe(true);
61+
expect(issues[0].message).toContain('currencyConfig');
62+
});
63+
64+
it('passes a bare number on the currency field', () => {
65+
expect(judgeSeedRecordValues(stackWith([{ name: 'Alpha', budget: 10 }]))).toEqual([]);
66+
});
67+
68+
it('passes a value the load accepts after its rewrite: a numeric string, a blank, a scalar on a multi-value field', () => {
69+
expect(judgeSeedRecordValues(stackWith([
70+
{ name: 'Alpha', budget: '10', hours: '2.5', due: '', tags: 'alpha' },
71+
]))).toEqual([]);
72+
});
73+
74+
it('refuses what the load refuses about a value: an unreadable number, a bound, an option, a date', () => {
75+
const issues = judgeSeedRecordValues(stackWith([
76+
{ name: 'Alpha', hours: 'abc', stage: 'archived', due: 'yesterday' },
77+
{ name: 'Beta', hours: -1, tags: ['alpha', 'zzz'] },
78+
]));
79+
expect(issues.map((i) => [i.location, i.code])).toEqual([
80+
['data[0].records[0].hours', 'invalid_number'],
81+
['data[0].records[0].stage', 'invalid_option'],
82+
['data[0].records[0].due', 'invalid_date'],
83+
['data[0].records[1].hours', 'min_value'],
84+
['data[0].records[1].tags', 'invalid_option'],
85+
]);
86+
});
87+
88+
it('judges a picklist-bound field against the options the stack\'s picklist resolves', () => {
89+
expect(judgeSeedRecordValues(stackWith([{ name: 'Alpha', industry: 'tech' }]))).toEqual([]);
90+
const issues = judgeSeedRecordValues(stackWith([{ name: 'Alpha', industry: 'retail' }]));
91+
expect(issues.map((i) => [i.field, i.code])).toEqual([['industry', 'invalid_option']]);
92+
});
93+
94+
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', () => {
95+
expect(judgeSeedRecordValues(stackWith([
96+
{
97+
name: 'Alpha',
98+
hours: cel`1 + 1`,
99+
due: cel`daysFromNow(3)`,
100+
brief: seedFile('fixtures/brief.pdf'),
101+
owner: 'admin@example.com',
102+
sector: 'anything',
103+
},
104+
]))).toEqual([]);
105+
});
106+
107+
it('does not judge `required`: a row missing a required value is the load\'s (defaults and hooks may fill it)', () => {
108+
expect(judgeSeedRecordValues(stackWith([{ budget: 5 }, { name: null, budget: 6 }]))).toEqual([]);
109+
});
110+
111+
it('CONTROL: a field the object does not declare gets no answer from this check', () => {
112+
expect(judgeSeedRecordValues(stackWith([{ name: 'Alpha', no_such_field: { value: 10 } }]))).toEqual([]);
113+
});
114+
115+
it('CONTROL: a dataset for an object the stack does not declare is not judged', () => {
116+
expect(judgeSeedRecordValues({
117+
objects: [PROJECT],
118+
data: [{ object: 'sys_business_unit', records: [{ name: 'HQ', budget: { value: 1 } }] }],
119+
})).toEqual([]);
120+
});
121+
122+
it('judges a package body\'s datasets against every package\'s objects, located where they sit', () => {
123+
const issues = judgeSeedRecordValues({
124+
packages: [
125+
{ manifest: { id: 'com.example.core', name: 'core', version: '1.0.0', type: 'app', objects: [PROJECT] } },
126+
{ 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' } }] }] } },
127+
],
128+
});
129+
expect(issues.map((i) => [i.location, i.code])).toEqual([['packages[1].manifest.data[0].records[0].budget', 'invalid_number']]);
130+
});
131+
132+
it('reads only: the stack it is handed is unchanged', () => {
133+
const stack = stackWith([{ name: 'Alpha', budget: '10', tags: 'alpha', due: '' }]);
134+
const before = JSON.stringify(stack);
135+
judgeSeedRecordValues(stack);
136+
expect(JSON.stringify(stack)).toBe(before);
137+
});
138+
});

0 commit comments

Comments
 (0)