diff --git a/.changeset/seed-insert-replay-lint.md b/.changeset/seed-insert-replay-lint.md new file mode 100644 index 000000000..31248b8b4 --- /dev/null +++ b/.changeset/seed-insert-replay-lint.md @@ -0,0 +1,19 @@ +--- +"@objectstack/lint": patch +"@objectstack/cli": patch +--- + +feat(lint): warn on replay-unsafe `mode: 'insert'` seed datasets (#3434 follow-up) + +Seeds are replayed — they re-load on every dev-server boot and every package +re-publish, not applied once — so `mode: 'insert'` (the loader's one mode with +no existing-row check) duplicates its table on every restart. That footgun +shipped undetected until #3434 (showcase memberships grew 3 → 6 → 9). + +Adds `validateSeedReplaySafety` to `@objectstack/lint` (a pure `(stack) => Finding[]` +rule, ADR-0019) and wires it into `os validate` / `os lint`. Every `data[]` seed +declared with `mode: 'insert'` now gets an advisory warning that points at the +idempotent modes (`ignore` / `upsert`) and the `externalId` to match on — a +single natural-key field, or a COMPOSITE list of fields for a join / junction +table with no single key (`['team', 'project']`, the support #3434 added). It +catches the mistake at authoring time instead of on the second boot. diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx index 7787a0940..9f0a8a91a 100644 --- a/content/docs/data-modeling/seed-data.mdx +++ b/content/docs/data-modeling/seed-data.mdx @@ -65,7 +65,7 @@ record (matched by `externalId`). | Mode | Behavior | Use Case | |:-----|:---------|:---------| | `upsert` | Create if new, update if found | Default — idempotent for most data | -| `insert` | Create only, throw on duplicate | Append-only tables, audit logs | +| `insert` | Insert **every** record unconditionally — no existing-row check, so it **duplicates the table on each replay boot** ([#3434](https://github.com/objectstack-ai/objectstack/issues/3434)). Rarely what you want | Append-only rows loaded exactly once (never re-run) | | `update` | Update only, skip if not found | Migration patches on existing rows | | `ignore` | Create if new, silently skip duplicates | Bootstrap data that must not overwrite user edits | | `replace` | Insert without checking for an existing match — the seed loader does **not** delete anything itself; the target table must already be empty (or cleared by the caller) | Cache / lookup tables rebuilt from an already-truncated table | @@ -115,6 +115,15 @@ defineSeed(ExchangeRateCache, { }); ``` +### `insert` — Not Idempotent (Avoid for Replayed Seeds) + +Seeds are **replayed** — they re-load on every dev-server boot and every package +re-publish, not applied once. `insert` writes every record with **no existing-row +check**, so a replayed `insert` dataset duplicates its table on each restart +(a 3-row fixture becomes 6, then 9 — [#3434](https://github.com/objectstack-ai/objectstack/issues/3434)). +It is almost never the right mode: reach for `upsert` (or `ignore`) with a stable +`externalId` instead. `os validate` warns on any `mode: 'insert'` seed. + --- ## Environment Scoping @@ -210,6 +219,31 @@ const contactsSeed = defineSeed(Contact, { export const SeedData = [accountsSeed, contactsSeed]; ``` +### Composite `externalId` — Join / Junction Tables + +A join (junction) table linking two objects many-to-many has **no single-field +natural key** — the *pair* of foreign keys is what makes a row unique. Pass a +**list** of field names as the `externalId` so the seed runner can match existing +rows on the composite key and stay idempotent across replays. The foreign keys are +compared by their *resolved* record ids, which are stable between boots. + +```typescript +// A team can staff many projects; a project can have many teams. +// `showcase_project_membership` is the join row — unique on (team, project). +const membershipsSeed = defineSeed(ProjectMembership, { + mode: 'ignore', // skip pairs that already exist + externalId: ['team', 'project'], // composite natural key — both FKs + records: [ + { team: 'Experience', project: 'Website Relaunch', engagement: 'owner' }, + { team: 'Platform', project: 'Data Platform', engagement: 'owner' }, + { team: 'Platform', project: 'Website Relaunch', engagement: 'contributor' }, + ], +}); +``` + +Without a composite key such a table can only fall back to `mode: 'insert'`, which +duplicates on every replay boot ([#3434](https://github.com/objectstack-ai/objectstack/issues/3434)). + --- ## Dynamic Values (CEL) @@ -353,6 +387,7 @@ databases. | User records | `'email'` | | Generic named records | `'name'` (default) | | Externally sourced data | `'external_id'` | +| Join / junction tables (no single natural key) | a composite list, e.g. `['team', 'project']` | ### Scope demo data with `env` @@ -391,7 +426,7 @@ function defineSeed< >( objectDef: TObj, config: { - externalId?: string; // default: 'name' + externalId?: string | string[]; // single field, or a composite list (join tables); default: 'name' mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert' env?: Array<'prod' | 'dev' | 'test'>; // default: ['prod','dev','test'] records: Array>>; diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index c032947cc..5dc7835c2 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -9,7 +9,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.js'; import { lintDataModel } from '../lint/data-model-rules.js'; import { validateWidgetBindings } from '@objectstack/lint'; -import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateApprovalApprovers } from '@objectstack/lint'; +import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateApprovalApprovers, validateSeedReplaySafety } from '@objectstack/lint'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { scoreMetadata } from '../lint/score.js'; import { runMetadataEval } from '../lint/metadata-eval.js'; @@ -440,6 +440,21 @@ export function lintConfig(config: any): LintIssue[] { }); } + // ── Seed replay safety (framework#3434) ── + // Seeds are replayed on every boot / re-publish, so a `mode: 'insert'` dataset + // duplicates its table on every restart (the loader's insert path has no + // existing-row check). Advisory: the fix-it points at `ignore`/`upsert` + an + // `externalId` (single field, or a composite list for a join table). + for (const t of validateSeedReplaySafety(config)) { + issues.push({ + severity: t.severity, + rule: t.rule, + message: `${t.where}: ${t.message}`, + path: t.path, + fix: t.hint, + }); + } + return issues; } diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index af580d86c..6a928e40d 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -109,6 +109,12 @@ export { } from './validate-approval-approvers.js'; export type { ApprovalApproverFinding, ApprovalApproverSeverity } from './validate-approval-approvers.js'; +export { + validateSeedReplaySafety, + SEED_INSERT_MODE_DUPLICATES_ON_REPLAY, +} from './validate-seed-replay-safety.js'; +export type { SeedReplaySafetyFinding, SeedReplaySafetySeverity } from './validate-seed-replay-safety.js'; + export { validateSecurityPosture, SECURITY_OWD_UNSET, diff --git a/packages/lint/src/validate-seed-replay-safety.test.ts b/packages/lint/src/validate-seed-replay-safety.test.ts new file mode 100644 index 000000000..dc8075e07 --- /dev/null +++ b/packages/lint/src/validate-seed-replay-safety.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { + validateSeedReplaySafety, + SEED_INSERT_MODE_DUPLICATES_ON_REPLAY, +} from './validate-seed-replay-safety.js'; + +describe('validateSeedReplaySafety (framework#3434 replay-unsafe seed gate)', () => { + it('passes a stack with no seed data', () => { + expect(validateSeedReplaySafety({})).toHaveLength(0); + expect(validateSeedReplaySafety({ data: [] })).toHaveLength(0); + }); + + it('passes idempotent modes (ignore / upsert / update / replace) and the default (unset)', () => { + const findings = validateSeedReplaySafety({ + data: [ + { object: 'a', mode: 'ignore', externalId: ['team', 'project'], records: [] }, + { object: 'b', mode: 'upsert', externalId: 'code', records: [] }, + { object: 'c', mode: 'update', records: [] }, + { object: 'd', mode: 'replace', records: [] }, + { object: 'e', records: [] }, // mode unset → defaults to upsert, safe + ], + }); + expect(findings).toHaveLength(0); + }); + + it("flags a mode: 'insert' seed with location + an actionable fix hint", () => { + const findings = validateSeedReplaySafety({ + data: [ + { object: 'showcase_project_membership', mode: 'insert', records: [{ team: 'x', project: 'y' }] }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: SEED_INSERT_MODE_DUPLICATES_ON_REPLAY, + path: 'data[0].mode', + }); + expect(findings[0].where).toContain('showcase_project_membership'); + expect(findings[0].message).toContain('replay'); + // Hint steers to the idempotent modes and the composite externalId the fix added. + expect(findings[0].hint).toContain('ignore'); + expect(findings[0].hint).toContain('upsert'); + expect(findings[0].hint).toContain('externalId'); + expect(findings[0].hint).toContain("['team', 'project']"); + }); + + it('flags each insert seed and reports its index in the path; leaves safe seeds alone', () => { + const findings = validateSeedReplaySafety({ + data: [ + { object: 'safe', mode: 'upsert', records: [] }, + { object: 'bad_one', mode: 'insert', records: [] }, + { object: 'also_safe', mode: 'ignore', records: [] }, + { object: 'bad_two', mode: 'insert', records: [] }, + ], + }); + expect(findings).toHaveLength(2); + expect(findings.map((f) => f.path)).toEqual(['data[1].mode', 'data[3].mode']); + expect(findings.map((f) => f.where)).toEqual(['seed "bad_one"', 'seed "bad_two"']); + }); + + it('falls back to a data[i] location when a seed has no object name', () => { + const findings = validateSeedReplaySafety({ data: [{ mode: 'insert', records: [] }] }); + expect(findings).toHaveLength(1); + expect(findings[0].where).toBe('data[0]'); + }); + + it('ignores non-object entries defensively', () => { + const findings = validateSeedReplaySafety({ data: [null, 'insert', 42, { object: 'z', mode: 'insert', records: [] }] }); + expect(findings).toHaveLength(1); + expect(findings[0].where).toContain('z'); + }); +}); diff --git a/packages/lint/src/validate-seed-replay-safety.ts b/packages/lint/src/validate-seed-replay-safety.ts new file mode 100644 index 000000000..63c1d174f --- /dev/null +++ b/packages/lint/src/validate-seed-replay-safety.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Build-time guardrail for replay-unsafe seed datasets (framework#3434). +// +// A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` and +// reusable by AI authoring. Seeds are REPLAYED — they re-load on every +// dev-server boot and every package re-publish, not applied once — so a +// dataset's mode has to be idempotent. `mode: 'insert'` is the one mode that +// is not: the loader's `insert` path writes every record unconditionally, with +// no existing-row check, so the table grows by the dataset's size on every +// restart (the showcase `showcase_project_membership` fixture went 3 → 6 → 9). +// +// This is the authoring-time nudge that would have caught #3434 before boot: +// flag `insert`, and point at the idempotent modes (`ignore` / `upsert`) plus +// the `externalId` — single field, or a COMPOSITE list of fields for a join / +// junction table that has no single natural key (`['team', 'project']`), the +// support for which #3434 added. +// +// Advisory (warning): an `insert` seed is not a schema error — it loads and +// "works" on a fresh DB; the defect only shows on the second boot. So it earns +// a located fix-it, not a hard `os compile` gate. + +export type SeedReplaySafetySeverity = 'error' | 'warning'; + +export interface SeedReplaySafetyFinding { + severity: SeedReplaySafetySeverity; + rule: string; + /** Human-readable location, e.g. `seed "showcase_project_membership"`. */ + where: string; + /** Config path, e.g. `data[12].mode`. */ + path: string; + message: string; + hint: string; +} + +// Rule id (registry entry). +export const SEED_INSERT_MODE_DUPLICATES_ON_REPLAY = 'seed-insert-mode-duplicates-on-replay'; + +type AnyRec = Record; + +/** + * Flag every seed dataset declared with `mode: 'insert'` — the one non-idempotent + * mode, which duplicates its rows on every replay boot (framework#3434). Returns + * the findings (empty = clean). The caller decides how to surface them / whether + * to fail the build; the CLI folds them in as advisory warnings. + * + * Reads `stack.data` (the `SeedSchema[]` fixtures). Safe on any shape — a stack + * with no `data` array yields no findings. + */ +export function validateSeedReplaySafety(stack: AnyRec): SeedReplaySafetyFinding[] { + const out: SeedReplaySafetyFinding[] = []; + const seeds = Array.isArray(stack.data) ? (stack.data as AnyRec[]) : []; + + seeds.forEach((seed, i) => { + if (!seed || typeof seed !== 'object') return; + if (seed.mode !== 'insert') return; + + const object = typeof seed.object === 'string' ? seed.object : undefined; + const where = object ? `seed "${object}"` : `data[${i}]`; + + out.push({ + severity: 'warning', + rule: SEED_INSERT_MODE_DUPLICATES_ON_REPLAY, + where, + path: `data[${i}].mode`, + message: + "`mode: 'insert'` re-inserts every record on each replay boot (dev-server restart, " + + 'package re-publish) with no existing-row check, so the dataset duplicates the table ' + + 'on every restart — seeds are replayed, not applied once.', + hint: + "Use `mode: 'ignore'` (skip rows that already exist) or `'upsert'` (create-or-update), " + + "and declare an `externalId` to match on: a single natural-key field (e.g. `externalId: 'code'`), " + + 'or a COMPOSITE list of fields for a join / junction table with no single natural key ' + + "(e.g. `externalId: ['team', 'project']`).", + }); + }); + + return out; +}