Skip to content

Commit fe5ef8c

Browse files
fix(spec)!: defineSeed refuses a record key the target object does not have (#22294)
Fixes #22149 Clause-②: yes (narrowing: a misspelled seed key is refused where it passed, while the record type now admits the injected system columns and `InjectedSystemColumnName` is a new public export, which widen the published surface) ## What changes - **`defineSeed(obj, config)` checks every record key when it runs.** A key must be a field `obj` declares or a system column the platform injects on `obj`. That set is `resolveInjectedSystemColumns(obj).names`, the per-object answer the registry's own `applySystemFields` reads. Every unknown key is refused in one error naming the object, the record index and the key, with a near-miss suggestion. The shape is the one `ObjectSchema.create()` uses for an unknown object key: ```text defineSeed('crm_case'): unknown field(s) in records — created_atx. • records[0]: `created_atx` is not a field of `crm_case`. Did you mean 'created_at'? ``` - **The record type admits the injectable system columns.** The new exported type `InjectedSystemColumnName` comes from `packages/spec/src/data/injected-system-columns.ts`: a record literal writing `created_at` or `owner_id` now passes `tsc`, where it used to be refused. The type cannot evaluate an object's opt-outs, so the call narrows it per object. `created_at` on a `systemFields: false` object, or `owner_id` on an `ownership: 'org'` object, is refused when the call runs. - **The docblock and `content/docs/data-modeling/seed-data.mdx` say exactly what each half enforces.** The compile-time half is TypeScript's excess-property check on a record literal. The call-time half checks every record. **Which leg, named with the measurement (triage asked the claimant to name it): both.** The type is exact wherever TypeScript's excess-property check runs, but that cannot hold for every record or object shape. So the check that holds for every record is the call-time one. `os validate`, `os build` and boot reach it by evaluating the config module, so no `packages/cli` edit was needed. ## Premises, measured - **H1, the record type widens to `string` keys for an `ObjectSchema.create()` object: FALSIFIED.** Compiled hotcrm's own `crm_case` object (`src/service/objects/case.object.ts` at hotcrm `99d290a`) against the published `@objectstack/spec` 17.7.0 tarball. `keyof` its `fields` is 23 literal keys, and an inline `{ subject, created_atx }` record is TS2353. What actually silences the check is the shape of hotcrm's `cases` seed: its `records` array also spreads an array typed as a string-keyed `Record` of unknown values. That spread turns off excess-property checking for every inline record beside it. Probe: inline misspelling plus that spread compiles clean, and the control (inline misspelling plus a spread of narrowly typed rows) is TS2353. The other holes measured on the source: records from a variable, and an object typed `ServiceObject` or an `ObjectSchema.parse()` result, whose keys are `string`. The same type refused `created_at` in a record literal (TS2353 on the 17.7.0 types too), so hotcrm's legitimate `created_at` compiled only through the spread hole. - **H2, `os validate` evaluates the seed module: HOLDS.** `loadConfig` evaluates the config through `bundleRequire`, which runs every module-level `defineSeed` call. Measured on `examples/app-todo` through the source CLI entry (`packages/cli/bin/run-dev.js validate`), with a misspelled key `categroy` injected into a seed record. Before (the call-time check disabled in spec's `dist`, marker proven present in 4 built files): exit 0, "Validation passed". After: exit 1, `defineSeed('todo_task'): unknown field(s) in records — categroy`, with "Did you mean 'category'?". Controls: the unmodified app exits 0, and `created_at` injected into a record exits 0, "Validation passed". Every injection was restored and hash-verified to the HEAD blob. - **H3, one declared source for the system columns: HOLDS, for the question the check asks.** `resolveInjectedSystemColumns(def).names` (`packages/spec/src/data/injected-system-columns.ts`) is "every column addressable on this object without being authored". It holds the driver's `id`, plus `organization_id`, the four audit columns, `owner_id` and `owning_business_unit_id` as the object's `systemFields`, `tenancy`, `ownership` and `managedBy` select them. The registry's `applySystemFields` consumes the same plan. No second list is written: the type-level union is read off the same constants, and the function now builds `names` as a Set of `InjectedSystemColumnName`, so a column it starts adding without widening the union is a compile error. What it answers is existence, not "the loader honours this value": see Acceptance notes. ## Landing outside the claimed file surface - `packages/spec/src/data/injected-system-columns.ts`: one exported type plus the typed Set. The type half has to admit the system columns without a hand-written list, and the declared source lives here. - `content/docs/data-modeling/seed-data.mdx`: the page made the same promise the docblock made (lines 7-9 and 48: TypeScript validates every record key), quoted a stale error text, and showed a stale `records` signature. Now it states both halves. Its CEL example writing `created_at`, `owner_id` and `organization_id` inline compiles under the new type. - `packages/spec/api-surface/data.json` and `export-origins/data.json`: regenerated by `check:generated --fix`, one added type entry each. ## Tests Final gate union at `d6ab9ae9c`. The spec suites ran at `2e559a83d`. The only commit since, `d6ab9ae9c`, touches only the changeset file. - **New `packages/spec/src/data/define-seed-record-keys.test.ts`, 9 cases.** The docblock's own unknown-key example fails `tsc` (a `@ts-expect-error` in the `tsconfig.test.json` program; the file is in its 2283-file `--listFilesOnly` list) and is refused when it runs. Also refused when they run: a misspelling beside the spread of untyped rows, records from a variable, an object typed `ServiceObject`, and several unknown keys collected into one error. The per-object plan refuses `created_at` on `systemFields: false` and `owner_id` on `ownership: 'org'`, while `id` is still accepted. Controls: a declared-fields-only seed passes and returns the parsed seed. The system-field control passes: `created_at`, `id`, `owner_id` and `organization_id` pass `tsc` and the call. - **Ablation U1 (runtime check disabled by an early return): 7 refusal cases red, 2 controls green.** The first U1 attempt was a no-op: the replacement still contained the anchor, so the tool refused and restored, and nothing ran. Re-anchored, the mutation landed (anchor 1 to 0). - **Ablation T1 (`SeedRecord` admitting any key): the test file's directive becomes TS2578 "Unused '@ts-expect-error' directive".** The narrow program with the same `tsconfig.test.json` settings is clean on HEAD. - **Door ablation and its restore leg.** Described under H2. After restoring, spec was rebuilt; the marker is absent from all 232 built files, and the whole tree is clean against HEAD. - **`pnpm --filter @objectstack/spec test`: 626 files, 18670 passed, 1 todo.** `test:repo`: 53 files, 903 passed. `typecheck`: exit 0 (src `tsc`, scripts, and the test layer held by `test-typecheck-debt.json`). - **Consumers.** The `defineSeed` callers were re-taken from the tree: `examples/app-crm`, `examples/app-showcase`, `examples/app-todo` and `packages/qa/dogfood`. `organizations` and `plugin-security` only mention it in comments, and `spec/scripts/schema-index.test.ts` holds it in a string fixture. Results: - `example-todo`: 7 files, 238 passed; `typecheck` exit 0. - `example-crm`: 5 files, 45 passed; `typecheck` exit 0. - `example-showcase`: 33 files, 408 passed; `typecheck` exit 0. - dogfood `seed-ownership-claim-dispatch.dogfood.test.ts`: 1 passed; `typecheck` exit 0. Each seed module is in its package's `tsc` program (`--listFilesOnly`). The rest of the dogfood suite is declared to CI. - **Records the narrowing refuses: none.** Every seed module was evaluated against the rebuilt `dist`. app-crm passes (5 seeds, 28 records), app-showcase passes (19 seeds, 132 records) and app-todo passes (1 seed, 8 records). In the same resolution context, a misspelled record throws, which is the control. hotcrm at `99d290a`, with `@objectstack/spec` resolved to this build: all 8 seed modules pass (354 records, including the `created_at` its case seeds author), and its `crm_case` with `created_atx` is refused. - **Gates.** `dispatch-gates --commands` derives 108 (the dispatch list's 86, plus 22 docs families from the page edit, `check:generated` and `check:skill-examples`). Run at `d6ab9ae9c`: 108 run, all exit 0. `--ran` verdict: "108 derived famil(ies) accounted for — 108 run, 0 NOT-MEASURED (a DERIVED zero — all 108 recorded an exit code and none of them is 3)". An earlier pass at `2e559a83d` had three non-zero results, all resolved: - `check-adr-0087-registration` exit 1: a FROM to TO table contradicted the `no-migration-prescription` disposition. The remedy is now prose, as precedent `22019` does. - `check:skill-examples` and `check:dual-build-cjs-loads` exit 3: PREREQUISITE NOT MET, unbuilt packages. After the prerequisites were built, both exit 0. - **Lint, a declared narrowing.** The repo sweep is CI's. ① Population: the `eslint.config.mjs` `files` globs match only code extensions, so this diff's lintable files are the 3 changed `.ts` files. The other 4 are `.md`, `.mdx` and `.json`. ② Count: eslint `--format json` reports 3 files, 0 errors, 0 warnings. ③ Invariance: the config enables no type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move the verdict on an untouched file. ## Changeset `minor`, **BREAKING**, the launch-window grade for accept-set narrowings. This is not a type-only change. The refusal is a call-time verdict, reached at `os validate`, `os build` and boot. ADR-0087 disposition: `not-required (no-migration-prescription)`. No key, spelling, export or stored shape moves; the only export change is an added type. The repair is the author's edit of a misspelled key, which no ledger entry can derive. ## Acceptance notes (noted, not filed) - **`skills/objectstack-data/references/seeds.md` (governed, not edited here).** Lines 5-6 say TypeScript checks every record's keys, which holds only for record literals; the call checks every record. Line 54 gives the `records` type as a partial record over the object's field keys. That is stale: the type is `SeedRecord`, which adds the injectable system columns and narrows reference values. Its CEL example (lines 114-121) writes `created_at`, `owner_id` and `organization_id` inline; it compiles under the new type. - **Seeds not built with `defineSeed` get no authoring-time key check.** That covers a plain seed literal in `defineStack({ data })`, `SeedSchema.parse()`, and a runtime `seed` draft. For these, the engine's declared-field door on insert (`undeclaredWriteFieldErrors`, `INVALID_FIELD`) is what refuses an undeclared key. That is a code reading, not measured here. - **Existence is not honour.** The check accepts `updated_at` because the column exists, but the insert audit stamp overwrites an authored `updated_at` (only `created_at` is kept for a seed). That is a code reading of objectql's audit binder: value semantics, not this card. - **Injected lookup columns take `unknown` values.** For `owner_id`, `created_by`, `updated_by`, `organization_id` and `owning_business_unit_id` in a record literal, the value type is `unknown` unless the object declares the field itself. So `SeedFieldValue`'s natural-key narrowing does not apply to them. Their definitions are not literally typed, so the narrowing cannot be derived from the source without a second list. - **The refusal carries no ADR-0112 `code`.** It is a plain `Error`, the same as `ObjectSchema.create()`'s unknown-key refusal. A code would be a new ledger entry, a naming decision not taken here. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 79c35d4 commit fe5ef8c

7 files changed

Lines changed: 328 additions & 20 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`defineSeed()` refuses a seed record key that names no column of the target object, whatever shape the records arrive in, and its record type now admits the system columns the platform injects (`created_at`, `owner_id` and the rest), which it used to refuse in a record literal.
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the define helper's verdict on record keys: a key that names neither a field the object declares nor a system column the platform injects on it is refused when `defineSeed` runs, at module load, which `os validate`, `os build` and boot all reach. The repair is the author's edit of a misspelled key, which no ledger entry can derive. The only export change is an added type, `InjectedSystemColumnName`. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this helper and this diff adds none (not registered / already-registered); and the refusal is a call-time verdict reached at the build doors, not a type surface alone (not type-surface-only). -->
10+
11+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. It is a call-time refusal, not a type-only change: the check runs when `defineSeed` is called, so it is reached by `os validate`, `os build` and boot through the config module's evaluation.
12+
13+
**Why.** The docblock promised that "typos in record field names are caught at compile time", and nothing else checked them. The promise held only for a record written as an object literal directly in `records`. TypeScript's excess-property check is the only thing the record type enforced, and it does not run for records from a variable or a `.map()`, for an object typed `ServiceObject`, or for any inline record in an array that also spreads a `Record<string, unknown>[]`. A seed with a misspelled key passed `tsc` and `objectstack validate`, and the mistake surfaced, if at all, only when the seed loaded. Measured with the published 17.7.0 types on a real app: an `ObjectSchema.create()` object keeps its literal field keys, and the spread is what silenced the check. The same type refused `created_at` in a record literal, a key the seed loader keeps on insert, so the one legitimate way to seed it was the shape that also hid typos.
14+
15+
**What is refused.** `defineSeed(obj, config)` throws when any record carries a key that is neither one of `obj.fields` nor a system column the platform injects on `obj`. The injected set is `resolveInjectedSystemColumns(obj).names`, the same per-object answer the registry's injection reads. It holds the driver's `id` always, plus `organization_id`, the audit columns (`created_at`, `created_by`, `updated_at`, `updated_by`), `owner_id` and `owning_business_unit_id` as the object's `systemFields`, `tenancy`, `ownership` and `managedBy` select them. All unknown keys are reported in one error, one line per key, naming the object, the record index and the key, with a near-miss suggestion:
16+
17+
```text
18+
defineSeed('crm_case'): unknown field(s) in records — created_atx.
19+
• records[0]: `created_atx` is not a field of `crm_case`. Did you mean 'created_at'?
20+
```
21+
22+
**What is admitted that was not.** The record type adds every injectable system column name (the new exported type `InjectedSystemColumnName`) to the keys a record literal may carry, typed `unknown`. A literal writing `created_at` or `owner_id` now passes `tsc`. The type cannot evaluate an object's opt-outs, so the call narrows it: `created_at` on a `systemFields: false` object, or `owner_id` on an `ownership: 'org'` object, is refused when the call runs. A declared field of the same name keeps its declared value type.
23+
24+
**Remedy.** Correct, declare or remove the key the refusal names. A misspelled key takes the spelling the refusal suggests (the declared field, or the system column such as `created_at`). A key for a field the object does not declare needs that field declared on the object, or the key removed. A system column the object opts out of (`created_at` on `systemFields: false`, `owner_id` on `ownership: 'org'`) does not exist on that object, so the key is removed. The key named no column of the object, so no value it carried could be stored under it, and no working seed depends on it.
25+
26+
**Who is affected, measured.** At `0767335c`, every `defineSeed` call in this repository passes: `examples/app-crm` (5 seeds, 28 records), `examples/app-showcase` (19 seeds, 132 records) and `examples/app-todo` (1 seed, 8 records), evaluated against the built package. A misspelled key in the same context is refused, which is the control. Every seed module in hotcrm at `99d290a` passes too (8 modules, 354 records, including the `created_at` its case seeds author), and its `crm_case` with the misspelled `created_atx` is refused. Other repositories and deployed packages were not measured.

‎content/docs/data-modeling/seed-data.mdx‎

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ navTitle: Seed Data & Fixtures
44
description: Populate ObjectStack objects with bootstrap data, reference records, and demo fixtures using defineSeed()
55
---
66

7-
`defineSeed()` is the canonical way to define seed data in ObjectStack. It provides
8-
compile-time type safety by inferring valid field keys directly from your object
9-
definition, so typos in record field names are caught before the code runs.
7+
`defineSeed()` is the canonical way to define seed data in ObjectStack. It checks
8+
every record key against your object definition, so a typo in a record field name
9+
is caught before any row is loaded: by TypeScript for a record you write inline,
10+
and by `defineSeed()` itself, for every record, when the config is loaded
11+
(`objectstack validate`, `objectstack build`, boot).
1012

1113
Use seed data for:
1214

@@ -44,8 +46,9 @@ export const accountsSeed = defineSeed(Account, {
4446
```
4547

4648
The first argument is the **object definition** (the exported constant from your
47-
object file), not a string. This lets TypeScript validate every field name in
48-
`records` against the object's `fields` map at compile time.
49+
object file), not a string. This lets `defineSeed()` check every field name in
50+
`records` against the object's `fields` map. See [Type Safety](#type-safety) for
51+
which half TypeScript checks and which half the call checks.
4952

5053
<Callout type="warn">
5154
Import `defineSeed` from `@objectstack/spec/data`. Do not confuse it with
@@ -255,9 +258,14 @@ operator's job — to switch locales cleanly, start from a fresh database.
255258

256259
## Type Safety
257260

258-
`defineSeed()` infers valid field keys from the object definition you pass as the
259-
first argument. If you reference a field that does not exist on the object, TypeScript
260-
reports an error immediately.
261+
A record key must name a column of the object: a field it declares, or a system
262+
column the platform injects on it (`id`, `organization_id`, `created_at`,
263+
`created_by`, `updated_at`, `updated_by`, `owner_id`, `owning_business_unit_id`,
264+
unless the object opts out of them with `systemFields`, `tenancy`, `ownership` or
265+
`managedBy`). `defineSeed()` checks this twice.
266+
267+
**At compile time**, for a record written as an object literal directly in
268+
`records`, TypeScript reports an unknown key immediately:
261269

262270
```typescript
263271
import { Account } from './objects/account.object';
@@ -269,14 +277,30 @@ defineSeed(Account, {
269277
typo_fild: 'value',
270278
// ^^^^^^^^^
271279
// TS Error: Object literal may only specify known properties,
272-
// and 'typo_fild' does not exist in type 'Partial<Record<keyof ...>>'
280+
// and 'typo_fild' does not exist in type 'SeedRecord<...>'
273281
},
274282
],
275283
});
276284
```
277285

286+
This is TypeScript's excess-property check, so it does not see a record that is
287+
not a fresh literal in the call: one from a variable or a `.map()`, any inline
288+
record in an array that also spreads a `Record<string, unknown>[]`, or a record
289+
for an object typed `ServiceObject`. It admits every system column name, because
290+
a type cannot evaluate the object's opt-outs.
291+
292+
**When the call runs** (when `objectstack validate`, `objectstack build` or boot
293+
loads your config), `defineSeed()` checks every record, whatever its shape,
294+
against the object's declared fields and the system columns injected on that
295+
object, and refuses the seed naming each unknown key:
296+
297+
```text
298+
defineSeed('crm_account'): unknown field(s) in records — typo_fild.
299+
• records[0]: `typo_fild` is not a field of `crm_account`.
300+
```
301+
278302
This is a major advantage over writing plain JSON — always use `defineSeed()`
279-
over the raw `SeedSchema.parse()` call.
303+
over the raw `SeedSchema.parse()` call, which checks no record key.
280304

281305
---
282306

@@ -565,11 +589,15 @@ function defineSeed<
565589
mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert'
566590
env?: Array<'prod' | 'dev' | 'test'>; // default: ['prod','dev','test']
567591
locale?: string[]; // BCP-47 tags; omitted = every locale
568-
records: Array<Partial<Record<keyof TObj['fields'], unknown>>>;
592+
records: Array<SeedRecord<TObj['fields']>>; // declared fields + injectable system columns
569593
}
570594
): Seed
571595
```
572596

597+
`SeedRecord` keys are the object's declared fields (a `lookup` / `master_detail`
598+
field takes its target's natural-key string) plus the injectable system column
599+
names. Every record key is checked again when the call runs.
600+
573601
The returned `Seed` object is a plain serialisable value — pass it to your
574602
stack's seed runner or store it in an export array.
575603

‎packages/spec/api-surface/data.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@
400400
"ImportMappingTargetVerdict (type)",
401401
"IndexSchema (const)",
402402
"InjectedColumnProvenance (type)",
403+
"InjectedSystemColumnName (type)",
403404
"InjectedSystemColumnPlan (interface)",
404405
"InlineGridColumn (type)",
405406
"InlineGridColumnParsed (type)",

‎packages/spec/export-origins/data.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@
390390
"ImportMappingTargetVerdict": "src/data/import-mapping-target.ts#ImportMappingTargetVerdict (type)",
391391
"IndexSchema": "src/data/object.zod.ts#IndexSchema (const)",
392392
"InjectedColumnProvenance": "src/data/injected-system-column-provenance.ts#InjectedColumnProvenance (type)",
393+
"InjectedSystemColumnName": "src/data/injected-system-columns.ts#InjectedSystemColumnName (type)",
393394
"InjectedSystemColumnPlan": "src/data/injected-system-columns.ts#InjectedSystemColumnPlan (interface)",
394395
"InlineGridColumn": "src/data/field.zod.ts#InlineGridColumn (type)",
395396
"InlineGridColumnParsed": "src/data/field.zod.ts#InlineGridColumnParsed (type)",
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `defineSeed` checks every record key against the object it seeds: at compile
5+
* time for a record literal (TypeScript's excess-property check over the
6+
* object's declared fields plus the injectable system columns), and when it
7+
* runs for every record, whatever its shape (declared fields plus the system
8+
* columns `resolveInjectedSystemColumns` gives THIS object).
9+
*
10+
* The `@ts-expect-error` lines are the compile-time half: this file is in the
11+
* `tsconfig.test.json` program `check:test-typecheck` compiles, so a type that
12+
* stopped refusing the key turns the directive into TS2578 there.
13+
*/
14+
15+
import { describe, expect, it } from 'vitest';
16+
import { Field } from './field.zod';
17+
import { ObjectSchema, type ServiceObject } from './object.zod';
18+
import { defineSeed } from './seed.zod';
19+
20+
const Lead = ObjectSchema.create({
21+
name: 'crm_lead',
22+
fields: {
23+
first_name: Field.text({ label: 'First Name' }),
24+
lead_source: Field.text({ label: 'Lead Source' }),
25+
account: Field.lookup('crm_account', { label: 'Account' }),
26+
},
27+
});
28+
29+
/** The error `fn` throws; fails the test when it returns instead. */
30+
function refusalOf(fn: () => unknown): Error {
31+
try {
32+
fn();
33+
} catch (error) {
34+
expect(error).toBeInstanceOf(Error);
35+
return error as Error;
36+
}
37+
throw new Error('expected defineSeed to refuse the seed, and it returned');
38+
}
39+
40+
describe('defineSeed refuses a record key the target object does not have', () => {
41+
it("the docblock's own ❌ example: an unknown key fails tsc, and is refused when it runs", () => {
42+
const error = refusalOf(() =>
43+
defineSeed(Lead, {
44+
externalId: 'first_name',
45+
// @ts-expect-error `source` is not a field of crm_lead (the defineSeed docblock's ❌ line)
46+
records: [{ first_name: 'Alice', lead_source: 'web' }, { source: 'web' }],
47+
}),
48+
);
49+
expect(error.message.split('\n')[0]).toContain("defineSeed('crm_lead')");
50+
expect(error.message).toContain('records[1]: `source` is not a field of `crm_lead`');
51+
});
52+
53+
it('a misspelled key beside a spread of untyped rows: tsc is silent, the call refuses it and suggests the column', () => {
54+
// A spread of `Record<string, unknown>[]` in the array literal turns off
55+
// TypeScript's excess-property check on every inline record beside it, so
56+
// no `@ts-expect-error` here: this is the shape that passed `tsc`.
57+
const generated = (): readonly Record<string, unknown>[] => [{ first_name: 'Gen' }];
58+
const error = refusalOf(() =>
59+
defineSeed(Lead, {
60+
externalId: 'first_name',
61+
records: [{ first_name: 'Carol', created_atx: '2026-01-01' }, ...generated()],
62+
}),
63+
);
64+
expect(error.message).toContain('records[0]: `created_atx` is not a field of `crm_lead`');
65+
expect(error.message).toContain("Did you mean 'created_at'?");
66+
});
67+
68+
it('records that never were a literal (a variable of untyped rows) are judged when the call runs', () => {
69+
const rows: Record<string, unknown>[] = [{ first_name: 'Dan' }, { first_name: 'Eve', lead_sorce: 'web' }];
70+
const error = refusalOf(() => defineSeed(Lead, { externalId: 'first_name', records: rows }));
71+
expect(error.message).toContain('records[1]: `lead_sorce` is not a field of `crm_lead`');
72+
expect(error.message).toContain("Did you mean 'lead_source'?");
73+
});
74+
75+
it('an object whose `fields` type is a string-keyed record (ServiceObject) is judged when the call runs', () => {
76+
const Widened: ServiceObject = Lead;
77+
const error = refusalOf(() =>
78+
defineSeed(Widened, { externalId: 'first_name', records: [{ first_name: 'Fay', firstname: 'Fay' }] }),
79+
);
80+
expect(error.message).toContain('records[0]: `firstname` is not a field of `crm_lead`');
81+
});
82+
83+
it('collects every unknown key across records into one refusal', () => {
84+
const rows: Record<string, unknown>[] = [{ first_name: 'A', zz_one: 1 }, { first_name: 'B' }, { zz_two: 2, zz_three: 3 }];
85+
const error = refusalOf(() => defineSeed(Lead, { externalId: 'first_name', records: rows }));
86+
expect(error.message.split('\n')[0]).toContain('zz_one, zz_two, zz_three');
87+
expect(error.message).toContain('records[0]: `zz_one`');
88+
expect(error.message).toContain('records[2]: `zz_two`');
89+
expect(error.message).toContain('records[2]: `zz_three`');
90+
});
91+
});
92+
93+
describe('defineSeed accepts every key that names a column of the target object', () => {
94+
it('control: a seed of declared fields only passes and returns the parsed seed', () => {
95+
const seed = defineSeed(Lead, {
96+
externalId: 'first_name',
97+
records: [
98+
{ first_name: 'Alice', lead_source: 'web' },
99+
{ first_name: 'Bob', account: 'Acme Corp' },
100+
],
101+
});
102+
expect(seed.object).toBe('crm_lead');
103+
expect(seed.records).toEqual([
104+
{ first_name: 'Alice', lead_source: 'web' },
105+
{ first_name: 'Bob', account: 'Acme Corp' },
106+
]);
107+
});
108+
109+
it('system-field control: created_at and the other injected columns pass tsc and the call', () => {
110+
const seed = defineSeed(Lead, {
111+
externalId: 'first_name',
112+
records: [
113+
{ first_name: 'Alice', created_at: '2026-01-01T00:00:00.000Z' },
114+
{ first_name: 'Bob', id: 'lead_bob', owner_id: 'admin@example.com', organization_id: 'org_1' },
115+
],
116+
});
117+
expect(seed.records[0]).toEqual({ first_name: 'Alice', created_at: '2026-01-01T00:00:00.000Z' });
118+
});
119+
120+
it('the injected set is THIS object\'s: created_at is refused on an object built with `systemFields: false`', () => {
121+
const Bare = ObjectSchema.create({
122+
name: 'crm_rate_card',
123+
systemFields: false,
124+
fields: { code: Field.text({ label: 'Code' }) },
125+
});
126+
// Compiles: the type admits every injectable name, because it cannot
127+
// evaluate the opt-out. The call reads the object's own plan.
128+
const error = refusalOf(() =>
129+
defineSeed(Bare, { externalId: 'code', records: [{ code: 'A', created_at: '2026-01-01' }] }),
130+
);
131+
expect(error.message).toContain('records[0]: `created_at` is not a field of `crm_rate_card`');
132+
// The driver's primary key exists even there.
133+
expect(defineSeed(Bare, { externalId: 'code', records: [{ code: 'B', id: 'rc_b' }] }).records).toHaveLength(1);
134+
});
135+
136+
it("the injected set is THIS object's: owner_id is refused on an `ownership: 'org'` object, created_at is not", () => {
137+
const OrgOwned = ObjectSchema.create({
138+
name: 'crm_region',
139+
ownership: 'org',
140+
fields: { code: Field.text({ label: 'Code' }) },
141+
});
142+
const error = refusalOf(() =>
143+
defineSeed(OrgOwned, { externalId: 'code', records: [{ code: 'EU', owner_id: 'admin@example.com' }] }),
144+
);
145+
expect(error.message).toContain('records[0]: `owner_id` is not a field of `crm_region`');
146+
expect(defineSeed(OrgOwned, { externalId: 'code', records: [{ code: 'NA', created_at: '2026-01-01' }] }).records)
147+
.toHaveLength(1);
148+
});
149+
});

‎packages/spec/src/data/injected-system-columns.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ const OWNING_BUSINESS_UNIT_COLUMN = 'owning_business_unit_id';
7272
/** THE tenant isolation key. */
7373
const TENANT_SCOPE_COLUMN = 'organization_id';
7474

75+
/**
76+
* Every name {@link resolveInjectedSystemColumns} can put in a plan's `names`,
77+
* on ANY object: the upper bound of the per-object answer, read off the same
78+
* constants the function adds. A compile-time consumer that cannot evaluate
79+
* the plan for a given object (the `defineSeed` record type) admits these
80+
* names, and leaves the per-object verdict to the plan at call time.
81+
*
82+
* The sync is the compiler's: the function builds `names` as a
83+
* `Set<InjectedSystemColumnName>`, so a column it starts adding without
84+
* widening this union is a compile error, not a second list that drifts.
85+
*/
86+
export type InjectedSystemColumnName =
87+
| typeof PRIMARY_KEY_COLUMN
88+
| typeof TENANT_SCOPE_COLUMN
89+
| (typeof AUDIT_PROVENANCE_FIELDS)[number]
90+
| typeof OWNER_COLUMN
91+
| typeof OWNING_BUSINESS_UNIT_COLUMN;
92+
7593
/**
7694
* Which system columns an object carries, as the four independent decisions the
7795
* injection pass makes plus the resolved name set.
@@ -144,7 +162,7 @@ export function resolveInjectedSystemColumns(def: unknown): InjectedSystemColumn
144162

145163
// The primary key is the driver's, not the injection pass's — it is present
146164
// even on the two "nothing is injected" rows below.
147-
const names = new Set<string>([PRIMARY_KEY_COLUMN]);
165+
const names = new Set<InjectedSystemColumnName>([PRIMARY_KEY_COLUMN]);
148166
const nothing: InjectedSystemColumnPlan = {
149167
tenant: false, audit: false, owner: false, owningBusinessUnit: false, names,
150168
};

0 commit comments

Comments
 (0)