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
26 changes: 26 additions & 0 deletions .changeset/22149-define-seed-record-keys.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- 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). -->

**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<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.

**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.
50 changes: 39 additions & 11 deletions content/docs/data-modeling/seed-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.

<Callout type="warn">
Import `defineSeed` from `@objectstack/spec/data`. Do not confuse it with
Expand Down Expand Up @@ -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';
Expand All @@ -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<Record<keyof ...>>'
// 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<string, unknown>[]`, 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.

---

Expand Down Expand Up @@ -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<Partial<Record<keyof TObj['fields'], unknown>>>;
records: Array<SeedRecord<TObj['fields']>>; // 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.

Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@
"ImportMappingTargetVerdict (type)",
"IndexSchema (const)",
"InjectedColumnProvenance (type)",
"InjectedSystemColumnName (type)",
"InjectedSystemColumnPlan (interface)",
"InlineGridColumn (type)",
"InlineGridColumnParsed (type)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
149 changes: 149 additions & 0 deletions packages/spec/src/data/define-seed-record-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[]` 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<string, unknown>[] => [{ 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<string, unknown>[] = [{ 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<string, unknown>[] = [{ 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);
});
});
20 changes: 19 additions & 1 deletion packages/spec/src/data/injected-system-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InjectedSystemColumnName>`, 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.
Expand Down Expand Up @@ -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<string>([PRIMARY_KEY_COLUMN]);
const names = new Set<InjectedSystemColumnName>([PRIMARY_KEY_COLUMN]);
const nothing: InjectedSystemColumnPlan = {
tenant: false, audit: false, owner: false, owningBusinessUnit: false, names,
};
Expand Down
Loading
Loading