diff --git a/.changeset/22149-define-seed-record-keys.md b/.changeset/22149-define-seed-record-keys.md new file mode 100644 index 00000000000..18bc56600ac --- /dev/null +++ b/.changeset/22149-define-seed-record-keys.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +--- + +`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. + +Clause-②: yes (narrowing) + + + +**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. + +**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[]`. 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. + +**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: + +```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'? +``` + +**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. + +**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. + +**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. diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx index 86dfe6f1976..3561aa8dd00 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -4,9 +4,11 @@ navTitle: Seed Data & Fixtures description: Populate ObjectStack objects with bootstrap data, reference records, and demo fixtures using defineSeed() --- -`defineSeed()` is the canonical way to define seed data in ObjectStack. It provides -compile-time type safety by inferring valid field keys directly from your object -definition, so typos in record field names are caught before the code runs. +`defineSeed()` is the canonical way to define seed data in ObjectStack. It checks +every record key against your object definition, so a typo in a record field name +is caught before any row is loaded: by TypeScript for a record you write inline, +and by `defineSeed()` itself, for every record, when the config is loaded +(`objectstack validate`, `objectstack build`, boot). Use seed data for: @@ -44,8 +46,9 @@ export const accountsSeed = defineSeed(Account, { ``` The first argument is the **object definition** (the exported constant from your -object file), not a string. This lets TypeScript validate every field name in -`records` against the object's `fields` map at compile time. +object file), not a string. This lets `defineSeed()` check every field name in +`records` against the object's `fields` map. See [Type Safety](#type-safety) for +which half TypeScript checks and which half the call checks. 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. ## Type Safety -`defineSeed()` infers valid field keys from the object definition you pass as the -first argument. If you reference a field that does not exist on the object, TypeScript -reports an error immediately. +A record key must name a column of the object: a field it declares, or a system +column the platform injects on it (`id`, `organization_id`, `created_at`, +`created_by`, `updated_at`, `updated_by`, `owner_id`, `owning_business_unit_id`, +unless the object opts out of them with `systemFields`, `tenancy`, `ownership` or +`managedBy`). `defineSeed()` checks this twice. + +**At compile time**, for a record written as an object literal directly in +`records`, TypeScript reports an unknown key immediately: ```typescript import { Account } from './objects/account.object'; @@ -269,14 +277,30 @@ defineSeed(Account, { typo_fild: 'value', // ^^^^^^^^^ // TS Error: Object literal may only specify known properties, - // and 'typo_fild' does not exist in type 'Partial>' + // and 'typo_fild' does not exist in type 'SeedRecord<...>' }, ], }); ``` +This is TypeScript's excess-property check, so it does not see a record that is +not a fresh literal in the call: one from a variable or a `.map()`, any inline +record in an array that also spreads a `Record[]`, or a record +for an object typed `ServiceObject`. It admits every system column name, because +a type cannot evaluate the object's opt-outs. + +**When the call runs** (when `objectstack validate`, `objectstack build` or boot +loads your config), `defineSeed()` checks every record, whatever its shape, +against the object's declared fields and the system columns injected on that +object, and refuses the seed naming each unknown key: + +```text +defineSeed('crm_account'): unknown field(s) in records — typo_fild. + • records[0]: `typo_fild` is not a field of `crm_account`. +``` + This is a major advantage over writing plain JSON — always use `defineSeed()` -over the raw `SeedSchema.parse()` call. +over the raw `SeedSchema.parse()` call, which checks no record key. --- @@ -565,11 +589,15 @@ function defineSeed< mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert' env?: Array<'prod' | 'dev' | 'test'>; // default: ['prod','dev','test'] locale?: string[]; // BCP-47 tags; omitted = every locale - records: Array>>; + records: Array>; // declared fields + injectable system columns } ): Seed ``` +`SeedRecord` keys are the object's declared fields (a `lookup` / `master_detail` +field takes its target's natural-key string) plus the injectable system column +names. Every record key is checked again when the call runs. + The returned `Seed` object is a plain serialisable value — pass it to your stack's seed runner or store it in an export array. diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 1f7f04170c2..637a28c8200 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -412,6 +412,7 @@ "ImportMappingTargetVerdict (type)", "IndexSchema (const)", "InjectedColumnProvenance (type)", + "InjectedSystemColumnName (type)", "InjectedSystemColumnPlan (interface)", "InlineGridColumn (type)", "InlineGridColumnParsed (type)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 68e506aa24a..2080b504f84 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -402,6 +402,7 @@ "ImportMappingTargetVerdict": "src/data/import-mapping-target.ts#ImportMappingTargetVerdict (type)", "IndexSchema": "src/data/object.zod.ts#IndexSchema (const)", "InjectedColumnProvenance": "src/data/injected-system-column-provenance.ts#InjectedColumnProvenance (type)", + "InjectedSystemColumnName": "src/data/injected-system-columns.ts#InjectedSystemColumnName (type)", "InjectedSystemColumnPlan": "src/data/injected-system-columns.ts#InjectedSystemColumnPlan (interface)", "InlineGridColumn": "src/data/field.zod.ts#InlineGridColumn (type)", "InlineGridColumnParsed": "src/data/field.zod.ts#InlineGridColumnParsed (type)", diff --git a/packages/spec/src/data/define-seed-record-keys.test.ts b/packages/spec/src/data/define-seed-record-keys.test.ts new file mode 100644 index 00000000000..dcbb75b83a2 --- /dev/null +++ b/packages/spec/src/data/define-seed-record-keys.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `defineSeed` checks every record key against the object it seeds: at compile + * time for a record literal (TypeScript's excess-property check over the + * object's declared fields plus the injectable system columns), and when it + * runs for every record, whatever its shape (declared fields plus the system + * columns `resolveInjectedSystemColumns` gives THIS object). + * + * The `@ts-expect-error` lines are the compile-time half: this file is in the + * `tsconfig.test.json` program `check:test-typecheck` compiles, so a type that + * stopped refusing the key turns the directive into TS2578 there. + */ + +import { describe, expect, it } from 'vitest'; +import { Field } from './field.zod'; +import { ObjectSchema, type ServiceObject } from './object.zod'; +import { defineSeed } from './seed.zod'; + +const Lead = ObjectSchema.create({ + name: 'crm_lead', + fields: { + first_name: Field.text({ label: 'First Name' }), + lead_source: Field.text({ label: 'Lead Source' }), + account: Field.lookup('crm_account', { label: 'Account' }), + }, +}); + +/** The error `fn` throws; fails the test when it returns instead. */ +function refusalOf(fn: () => unknown): Error { + try { + fn(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + return error as Error; + } + throw new Error('expected defineSeed to refuse the seed, and it returned'); +} + +describe('defineSeed refuses a record key the target object does not have', () => { + it("the docblock's own ❌ example: an unknown key fails tsc, and is refused when it runs", () => { + const error = refusalOf(() => + defineSeed(Lead, { + externalId: 'first_name', + // @ts-expect-error `source` is not a field of crm_lead (the defineSeed docblock's ❌ line) + records: [{ first_name: 'Alice', lead_source: 'web' }, { source: 'web' }], + }), + ); + expect(error.message.split('\n')[0]).toContain("defineSeed('crm_lead')"); + expect(error.message).toContain('records[1]: `source` is not a field of `crm_lead`'); + }); + + it('a misspelled key beside a spread of untyped rows: tsc is silent, the call refuses it and suggests the column', () => { + // A spread of `Record[]` in the array literal turns off + // TypeScript's excess-property check on every inline record beside it, so + // no `@ts-expect-error` here: this is the shape that passed `tsc`. + const generated = (): readonly Record[] => [{ first_name: 'Gen' }]; + const error = refusalOf(() => + defineSeed(Lead, { + externalId: 'first_name', + records: [{ first_name: 'Carol', created_atx: '2026-01-01' }, ...generated()], + }), + ); + expect(error.message).toContain('records[0]: `created_atx` is not a field of `crm_lead`'); + expect(error.message).toContain("Did you mean 'created_at'?"); + }); + + it('records that never were a literal (a variable of untyped rows) are judged when the call runs', () => { + const rows: Record[] = [{ first_name: 'Dan' }, { first_name: 'Eve', lead_sorce: 'web' }]; + const error = refusalOf(() => defineSeed(Lead, { externalId: 'first_name', records: rows })); + expect(error.message).toContain('records[1]: `lead_sorce` is not a field of `crm_lead`'); + expect(error.message).toContain("Did you mean 'lead_source'?"); + }); + + it('an object whose `fields` type is a string-keyed record (ServiceObject) is judged when the call runs', () => { + const Widened: ServiceObject = Lead; + const error = refusalOf(() => + defineSeed(Widened, { externalId: 'first_name', records: [{ first_name: 'Fay', firstname: 'Fay' }] }), + ); + expect(error.message).toContain('records[0]: `firstname` is not a field of `crm_lead`'); + }); + + it('collects every unknown key across records into one refusal', () => { + const rows: Record[] = [{ first_name: 'A', zz_one: 1 }, { first_name: 'B' }, { zz_two: 2, zz_three: 3 }]; + const error = refusalOf(() => defineSeed(Lead, { externalId: 'first_name', records: rows })); + expect(error.message.split('\n')[0]).toContain('zz_one, zz_two, zz_three'); + expect(error.message).toContain('records[0]: `zz_one`'); + expect(error.message).toContain('records[2]: `zz_two`'); + expect(error.message).toContain('records[2]: `zz_three`'); + }); +}); + +describe('defineSeed accepts every key that names a column of the target object', () => { + it('control: a seed of declared fields only passes and returns the parsed seed', () => { + const seed = defineSeed(Lead, { + externalId: 'first_name', + records: [ + { first_name: 'Alice', lead_source: 'web' }, + { first_name: 'Bob', account: 'Acme Corp' }, + ], + }); + expect(seed.object).toBe('crm_lead'); + expect(seed.records).toEqual([ + { first_name: 'Alice', lead_source: 'web' }, + { first_name: 'Bob', account: 'Acme Corp' }, + ]); + }); + + it('system-field control: created_at and the other injected columns pass tsc and the call', () => { + const seed = defineSeed(Lead, { + externalId: 'first_name', + records: [ + { first_name: 'Alice', created_at: '2026-01-01T00:00:00.000Z' }, + { first_name: 'Bob', id: 'lead_bob', owner_id: 'admin@example.com', organization_id: 'org_1' }, + ], + }); + expect(seed.records[0]).toEqual({ first_name: 'Alice', created_at: '2026-01-01T00:00:00.000Z' }); + }); + + it('the injected set is THIS object\'s: created_at is refused on an object built with `systemFields: false`', () => { + const Bare = ObjectSchema.create({ + name: 'crm_rate_card', + systemFields: false, + fields: { code: Field.text({ label: 'Code' }) }, + }); + // Compiles: the type admits every injectable name, because it cannot + // evaluate the opt-out. The call reads the object's own plan. + const error = refusalOf(() => + defineSeed(Bare, { externalId: 'code', records: [{ code: 'A', created_at: '2026-01-01' }] }), + ); + expect(error.message).toContain('records[0]: `created_at` is not a field of `crm_rate_card`'); + // The driver's primary key exists even there. + expect(defineSeed(Bare, { externalId: 'code', records: [{ code: 'B', id: 'rc_b' }] }).records).toHaveLength(1); + }); + + it("the injected set is THIS object's: owner_id is refused on an `ownership: 'org'` object, created_at is not", () => { + const OrgOwned = ObjectSchema.create({ + name: 'crm_region', + ownership: 'org', + fields: { code: Field.text({ label: 'Code' }) }, + }); + const error = refusalOf(() => + defineSeed(OrgOwned, { externalId: 'code', records: [{ code: 'EU', owner_id: 'admin@example.com' }] }), + ); + expect(error.message).toContain('records[0]: `owner_id` is not a field of `crm_region`'); + expect(defineSeed(OrgOwned, { externalId: 'code', records: [{ code: 'NA', created_at: '2026-01-01' }] }).records) + .toHaveLength(1); + }); +}); diff --git a/packages/spec/src/data/injected-system-columns.ts b/packages/spec/src/data/injected-system-columns.ts index 3679d0c4e49..c5095bd956c 100644 --- a/packages/spec/src/data/injected-system-columns.ts +++ b/packages/spec/src/data/injected-system-columns.ts @@ -72,6 +72,24 @@ const OWNING_BUSINESS_UNIT_COLUMN = 'owning_business_unit_id'; /** THE tenant isolation key. */ const TENANT_SCOPE_COLUMN = 'organization_id'; +/** + * Every name {@link resolveInjectedSystemColumns} can put in a plan's `names`, + * on ANY object: the upper bound of the per-object answer, read off the same + * constants the function adds. A compile-time consumer that cannot evaluate + * the plan for a given object (the `defineSeed` record type) admits these + * names, and leaves the per-object verdict to the plan at call time. + * + * The sync is the compiler's: the function builds `names` as a + * `Set`, so a column it starts adding without + * widening this union is a compile error, not a second list that drifts. + */ +export type InjectedSystemColumnName = + | typeof PRIMARY_KEY_COLUMN + | typeof TENANT_SCOPE_COLUMN + | (typeof AUDIT_PROVENANCE_FIELDS)[number] + | typeof OWNER_COLUMN + | typeof OWNING_BUSINESS_UNIT_COLUMN; + /** * Which system columns an object carries, as the four independent decisions the * injection pass makes plus the resolved name set. @@ -144,7 +162,7 @@ export function resolveInjectedSystemColumns(def: unknown): InjectedSystemColumn // The primary key is the driver's, not the injection pass's — it is present // even on the two "nothing is injected" rows below. - const names = new Set([PRIMARY_KEY_COLUMN]); + const names = new Set([PRIMARY_KEY_COLUMN]); const nothing: InjectedSystemColumnPlan = { tenant: false, audit: false, owner: false, owningBusinessUnit: false, names, }; diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts index 51b93d650af..67a8454d8ae 100644 --- a/packages/spec/src/data/seed.zod.ts +++ b/packages/spec/src/data/seed.zod.ts @@ -10,6 +10,8 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { LocaleSchema } from '../system/translation.zod'; +import { findClosestMatches, formatSuggestion } from '../shared/suggestions.zod'; +import { resolveInjectedSystemColumns, type InjectedSystemColumnName } from './injected-system-columns'; export const SeedMode = z.enum([ 'insert', // Try to insert, fail on duplicate 'update', // Only update found records, ignore new @@ -184,17 +186,97 @@ type SeedFieldValue = : string | null : unknown; -/** Shape of a single seed record, derived from the object's field definitions. */ +/** + * Shape of a single seed record, derived from the object's field definitions. + * + * Its keys are the object's declared `fields`, each typed by + * {@link SeedFieldValue}, plus the system columns the platform injects without + * the author declaring them ({@link InjectedSystemColumnName}: `created_at`, + * `owner_id` and the rest), typed `unknown`. The injected half is the union + * over EVERY object, because a type cannot evaluate an object's opt-outs + * (`systemFields`, `ownership`, `managedBy`); {@link defineSeed} narrows it to + * this object's own plan when it runs. A field the object declares under one of + * those names keeps its declared value type. + */ type SeedRecord = { [K in keyof TFields]?: SeedFieldValue; +} & { + [K in Exclude]?: unknown; }; /** - * Type-safe factory for creating seed definitions. - * Infers valid field keys from the object definition passed in, - * so typos in record field names are caught at compile time. Reference - * fields (lookup/master_detail) are additionally constrained to the - * natural-key string the loader resolves — see {@link SeedFieldValue}. + * The call-time half of {@link defineSeed}'s key check: every key of every + * record must name a column the target object has, which is a field it + * declares or a system column the platform injects on THIS object. The injected + * set is {@link resolveInjectedSystemColumns}, the per-object answer the + * registry's own injection consumes, so a seed may write `created_at` (the + * seed loader keeps an authored one on insert) on an object that carries the + * audit columns, and is refused it on one built with `systemFields: false`. + * + * It judges what the type cannot see: a record that does not reach the call as + * a fresh object literal (a variable, a `.map()` result, a spread of + * `Record[]`, which also silences TypeScript's check on every + * inline record beside it), and an object whose `fields` type is a + * string-keyed record (`ServiceObject`, an `ObjectSchema.parse()` result). + * + * All findings are collected and refused together, one line per key, in the + * shape `ObjectSchema.create()` refuses an unknown object key: the object and + * the key named, a near-miss suggested, the fix stated. + * + * No opinion without a field map: a caller outside the type that passes no + * `fields` object is not judged here. + */ +function assertSeedRecordKeysDeclared( + objectDef: { name: string; fields: unknown }, + records: ReadonlyArray>, +): void { + const { fields } = objectDef; + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return; + const known = [...Object.keys(fields), ...resolveInjectedSystemColumns(objectDef).names]; + const knownSet = new Set(known); + const lines: string[] = []; + const unknownKeys = new Set(); + records.forEach((record, index) => { + for (const key of Object.keys(record)) { + if (knownSet.has(key)) continue; + unknownKeys.add(key); + const suggestion = formatSuggestion( + findClosestMatches(key, known, Math.max(2, Math.floor(key.length / 3)), 1), + ); + lines.push( + ` • records[${index}]: \`${key}\` is not a field of \`${objectDef.name}\`.` + + (suggestion ? ` ${suggestion}` : ''), + ); + } + }); + if (lines.length === 0) return; + throw new Error( + `defineSeed('${objectDef.name}'): unknown field(s) in records — ${[...unknownKeys].join(', ')}.\n` + + `A seed record key must name a field \`${objectDef.name}\` declares or a system column the platform ` + + 'injects on it (`created_at`, `owner_id` and the rest, unless the object opts out of them).\n\n' + + `${lines.join('\n')}\n\n` + + `Fix the key's spelling, declare the field on \`${objectDef.name}\`, or remove the key from the record.`, + ); +} + +/** + * Type-safe factory for creating seed definitions. Every record key is checked + * against the object definition passed in, twice: + * + * - **At compile time**, for a record written as an object literal directly in + * `records`: its keys must be the object's declared `fields` or a system + * column the platform can inject (`created_at`, `owner_id`, … — see + * {@link SeedRecord}), and a reference field (lookup/master_detail) takes the + * natural-key string the loader resolves (see {@link SeedFieldValue}). This + * is TypeScript's excess-property check, so it sees only fresh literals on an + * object whose field keys are literal (`ObjectSchema.create()`): a record + * from a variable, a `.map()` or a spread, and an object typed `ServiceObject`, + * are not checked here. + * - **When it runs** (module load: `os validate`, `os build`, boot), for every + * record whatever its shape: each key must be a field the object declares or + * a system column the platform injects on THIS object + * ({@link resolveInjectedSystemColumns}). A key that is neither is refused, + * naming the object, the record and the key, with a near-miss suggestion. * * @example * ```ts @@ -202,7 +284,8 @@ type SeedRecord = { * externalId: 'email', * records: [ * { first_name: 'Alice', lead_source: 'web' }, // ✅ type-checked - * { source: 'web' }, // ❌ compile error (unknown field) + * { source: 'web' }, // ❌ compile error (unknown field), and refused when it runs + * { first_name: 'Bob', created_at: cel`daysAgo(3)` }, // ✅ an injected system column * { first_name: 'Bob', account: 'Acme Corp' }, // ✅ reference by natural key * { first_name: 'Bob', account: { externalId: 'Acme Corp' } }, // ❌ object not allowed * ], @@ -217,5 +300,7 @@ export function defineSeed< records: Array>; } ): Seed { - return SeedSchema.parse({ ...config, object: objectDef.name }); + const seed = SeedSchema.parse({ ...config, object: objectDef.name }); + assertSeedRecordKeysDeclared(objectDef, seed.records); + return seed; }