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
19 changes: 19 additions & 0 deletions .changeset/seed-insert-replay-lint.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 37 additions & 2 deletions content/docs/data-modeling/seed-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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<Partial<Record<keyof TObj['fields'], unknown>>>;
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions packages/lint/src/validate-seed-replay-safety.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
79 changes: 79 additions & 0 deletions packages/lint/src/validate-seed-replay-safety.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/**
* 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;
}