diff --git a/.changeset/seed-loader-composite-external-id.md b/.changeset/seed-loader-composite-external-id.md new file mode 100644 index 0000000000..dea8d998fe --- /dev/null +++ b/.changeset/seed-loader-composite-external-id.md @@ -0,0 +1,31 @@ +--- +"@objectstack/spec": patch +"@objectstack/metadata-protocol": patch +--- + +fix(seed-loader): support a composite `externalId` so join-table seeds dedupe on replay (#3434) + +A junction / join table has no single-field natural key — the PAIR of its +foreign keys is what's unique — so its seed could only run `mode: 'insert'`, +which re-inserts every row on each replay boot with no existing-row check +(`decideWriteAction`'s `insert` case returns `insert` unconditionally). The +table duplicated on every restart: the showcase `showcase_project_membership` +fixture (3 rows) grew 3 → 6 → 9. It was masked until #3415 let the master-detail +parents seed at all. + +- `SeedSchema.externalId` now accepts a **list** of field names + (`externalId: ['team', 'project']`) in addition to a single field name, + declaring a composite natural key. Default stays `'name'`. +- `SeedLoaderService` builds the uniqueness key from all listed fields (joined + with a `\u0000` separator that can't occur in a natural-key value). Reference + key fields are compared by their RESOLVED parent ids — which the existing DB + row already stores — so a composite of foreign keys matches across restarts. + A partial key (any component absent) is treated as no key, falling back to + insert, exactly as a missing single-field key already did. +- A composite-key target does not participate in single-value reference + resolution (a reference is one natural-key string), so such objects keep the + `'name'` default when referenced by another dataset. + +The showcase membership fixture switches to `mode: 'ignore'` + +`externalId: ['team', 'project']`, so replay boots leave the three rows +untouched instead of duplicating them. diff --git a/content/docs/references/data/seed-loader.mdx b/content/docs/references/data/seed-loader.mdx index 289787b32e..e1040fa5aa 100644 --- a/content/docs/references/data/seed-loader.mdx +++ b/content/docs/references/data/seed-loader.mdx @@ -183,7 +183,7 @@ Seed loader request with datasets and configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **seeds** | `{ object: string; externalId: string; mode: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | ✅ | Seeds to load | +| **seeds** | `{ object: string; externalId: string \| string[]; mode: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | ✅ | Seeds to load | | **config** | `{ dryRun: boolean; haltOnError: boolean; multiPass: boolean; defaultMode: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; … }` | ✅ | Loader configuration | diff --git a/content/docs/references/data/seed.mdx b/content/docs/references/data/seed.mdx index 950561afbd..805518cbf7 100644 --- a/content/docs/references/data/seed.mdx +++ b/content/docs/references/data/seed.mdx @@ -32,7 +32,7 @@ const result = Seed.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Target Object Name | -| **externalId** | `string` | ✅ | Field match for uniqueness check | +| **externalId** | `string \| string[]` | ✅ | Field (or composite list of fields) matched for the uniqueness check | | **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | ✅ | Conflict resolution strategy | | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | ✅ | Applicable environments | | **records** | `Record[]` | ✅ | Data records | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index a06b9b5c95..4a5e481c9b 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -61,7 +61,7 @@ const result = Manifest.parse(data); | **dependencies** | `Record` | optional | Package dependencies | | **configuration** | `{ title?: string; properties: Record; default?: any; description?: string; required?: boolean; … }> }` | optional | Plugin configuration settings | | **contributes** | `{ kinds?: { id: string; globs: string[]; description?: string }[]; events?: string[]; menus?: Record; themes?: { id: string; label: string; path: string }[]; … }` | optional | Platform contributions | -| **data** | `{ object: string; externalId?: string; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) | +| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) | | **capabilities** | `{ implements?: { protocol: object; conformance?: Enum<'full' \| 'partial' \| 'experimental' \| 'deprecated'>; implementedFeatures?: string[]; features?: { name: string; enabled?: boolean; description?: string; sinceVersion?: string; … }[]; … }[]; provides?: { id: string; name: string; description?: string; version: object; … }[]; requires?: { pluginId: string; version: string; optional?: boolean; reason?: string; … }[]; extensionPoints?: { id: string; name: string; description?: string; type: Enum<'action' \| 'hook' \| 'widget' \| 'provider' \| 'transformer' \| 'validator' \| 'decorator'>; … }[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … }[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index e49d66ae7c..61ee10fe4b 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -257,8 +257,14 @@ const products = defineSeed(Product, { ], }); +// A junction row has no single natural key — the (team, project) PAIR is what's +// unique — so `mode: 'insert'` re-inserted all three rows on every replay boot, +// duplicating the table 3 → 6 → 9 (framework#3434). A COMPOSITE externalId over +// the two foreign keys lets `ignore` mode dedupe on replay: team/project are +// matched by their resolved ids, which stay stable across restarts. const memberships = defineSeed(ProjectMembership, { - mode: 'insert', + mode: 'ignore', + externalId: ['team', 'project'], records: [ { team: 'Experience', project: 'Website Relaunch', engagement: 'owner', allocation_percent: 80 }, { team: 'Platform', project: 'Data Platform', engagement: 'owner', allocation_percent: 100 }, diff --git a/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts b/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts new file mode 100644 index 0000000000..a93a85e32e --- /dev/null +++ b/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { SeedLoaderService } from './seed-loader'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; + +/** + * Composite externalId (framework#3434). + * + * A join / junction table has no single-field natural key — the PAIR of its + * foreign keys is what's unique. Before composite externalId support, such a + * dataset could only run `mode: 'insert'`, which re-inserts every row on each + * replay boot and duplicates the table (the showcase memberships went 3→6→9). + * + * A composite `externalId: ['team', 'project']` + `mode: 'ignore'` dedupes the + * rows across restarts, matching on the RESOLVED parent ids (a reference key + * field is compared by the id it resolved to, which the existing DB row already + * stores). + * + * Uses a faithful engine (filters `where`, mints ids) — the seed-loader.test.ts + * mock ignores `where` and returns the whole table, which would mask replay + * behavior. Mirrors the createFaithfulEngine helper in seed-loader-replay.test.ts. + */ + +function createLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +function createFaithfulEngine(): { engine: IDataEngine; store: Record } { + const store: Record = {}; + let idCounter = 0; + + const engine = { + find: vi.fn(async (objectName: string, query?: any) => { + let records = store[objectName] || []; + if (query?.where) { + records = records.filter((r) => + Object.entries(query.where).every(([k, v]) => r[k] === v), + ); + } + if (typeof query?.limit === 'number') records = records.slice(0, query.limit); + return records; + }), + findOne: vi.fn(async (objectName: string, query?: any) => { + const rows = await (engine.find as any)(objectName, { ...query, limit: 1 }); + return rows[0] ?? null; + }), + insert: vi.fn(async (objectName: string, data: any) => { + if (!store[objectName]) store[objectName] = []; + if (Array.isArray(data)) { + const records = data.map((d) => ({ id: `gen-${++idCounter}`, ...d })); + store[objectName].push(...records); + return records; + } + const record = { id: `gen-${++idCounter}`, ...data }; + store[objectName].push(record); + return record; + }), + update: vi.fn(async (objectName: string, data: any) => { + const records = store[objectName] || []; + const idx = records.findIndex((r) => r.id === data.id); + if (idx >= 0) { + records[idx] = { ...records[idx], ...data }; + return records[idx]; + } + return data; + }), + delete: vi.fn(async () => ({ deleted: 1 })), + count: vi.fn(async (objectName: string) => (store[objectName] || []).length), + aggregate: vi.fn(async () => []), + } as unknown as IDataEngine; + + return { engine, store }; +} + +function createMetadata(): IMetadataService { + const objects: Record = { + demo_team: { name: 'demo_team', fields: { name: { type: 'text' } } }, + demo_project: { name: 'demo_project', fields: { name: { type: 'text' } } }, + // The join row: two required master_detail foreign keys, no single natural key. + demo_membership: { + name: 'demo_membership', + fields: { + team: { type: 'master_detail', reference: 'demo_team', required: true }, + project: { type: 'master_detail', reference: 'demo_project', required: true }, + engagement: { type: 'text' }, + }, + }, + }; + return { + getObject: vi.fn(async (name: string) => objects[name]), + listObjects: vi.fn(async () => Object.values(objects)), + register: vi.fn(async () => {}), + get: vi.fn(async (_t: string, name: string) => objects[name]), + list: vi.fn(async () => []), + unregister: vi.fn(async () => {}), + exists: vi.fn(async () => false), + listNames: vi.fn(async () => []), + } as unknown as IMetadataService; +} + +// Mirrors the AppPlugin inline-seed config (defaultMode upsert, multiPass on). +const CONFIG = { + dryRun: false, + haltOnError: false, + multiPass: true, + defaultMode: 'upsert', + batchSize: 1000, + transaction: false, +} as any; + +// Mirrors the showcase fixture: two teams, two projects, and three memberships +// keyed by the (team, project) pair. Two rows share team 'Platform' and two +// share project 'Website Relaunch', so NEITHER foreign key is unique alone — +// only the pair is. +const SEEDS = [ + { + object: 'demo_team', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], + records: [{ name: 'Experience' }, { name: 'Platform' }], + }, + { + object: 'demo_project', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], + records: [{ name: 'Website Relaunch' }, { name: 'Data Platform' }], + }, + { + object: 'demo_membership', externalId: ['team', 'project'], mode: 'ignore', env: ['prod', 'dev', 'test'], + records: [ + { team: 'Experience', project: 'Website Relaunch', engagement: 'owner' }, + { team: 'Platform', project: 'Data Platform', engagement: 'owner' }, + { team: 'Platform', project: 'Website Relaunch', engagement: 'contributor' }, + ], + }, +] as any[]; + +describe('seed composite externalId — join-table dedupe on replay (#3434)', () => { + it('inserts each (team, project) pair once and skips them all on replay (no 3→6→9)', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + + // Boot #1 — fresh DB: all three pairs are new, all insert. + const first = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + expect(first.success).toBe(true); + expect(store.demo_membership).toHaveLength(3); + expect(first.results.find((r) => r.object === 'demo_membership')!.inserted).toBe(3); + + // Foreign keys land as RESOLVED parent ids (not the raw natural-key strings). + const teamId = (name: string) => store.demo_team.find((t) => t.name === name)!.id; + const projectId = (name: string) => store.demo_project.find((p) => p.name === name)!.id; + expect(store.demo_membership.map((m) => `${m.team}|${m.project}`).sort()).toEqual( + [ + `${teamId('Experience')}|${projectId('Website Relaunch')}`, + `${teamId('Platform')}|${projectId('Data Platform')}`, + `${teamId('Platform')}|${projectId('Website Relaunch')}`, + ].sort(), + ); + + // Boot #2 (dev-server restart): the composite key matches the existing rows + // by resolved id, so all three skip — the table stays at 3, not 6. + const second = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + expect(second.success).toBe(true); + expect(store.demo_membership).toHaveLength(3); + const replay = second.results.find((r) => r.object === 'demo_membership')!; + expect(replay.inserted).toBe(0); + expect(replay.skipped).toBe(3); + + // Boot #3 — still 3 (the historical bug grew the table on every boot). + await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + expect(store.demo_membership).toHaveLength(3); + }); + + it('distinguishes pairs: a genuinely new (team, project) still inserts, sharing rows do not block it', async () => { + const { engine, store } = createFaithfulEngine(); + const metadata = createMetadata(); + + await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG }); + expect(store.demo_membership).toHaveLength(3); + + // A 4th membership reusing an EXISTING team and an EXISTING project, but a + // NEW pairing (Experience × Data Platform). Composite dedupe keys on the + // full pair, so this must insert — not skip because the team or the project + // already appears in some other row. + const grown = structuredClone(SEEDS); + grown[2].records.push({ team: 'Experience', project: 'Data Platform', engagement: 'reviewer' }); + + const res = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: grown, config: CONFIG }); + expect(res.success).toBe(true); + expect(store.demo_membership).toHaveLength(4); + const membership = res.results.find((r) => r.object === 'demo_membership')!; + expect(membership.inserted).toBe(1); // only the new pair + expect(membership.skipped).toBe(3); // the original three, unchanged + }); +}); diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index ab33b126ad..4f649459da 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -115,7 +115,7 @@ export class SeedLoaderService implements ISeedLoaderService { // the in-memory insertedRecords map (keyed by the dataset's externalId) // resolved everything, but on replay any per-record miss fell through to // the DB probe and silently failed. See the replay corruption fix below. - const externalIdByObject = new Map( + const externalIdByObject = new Map( request.seeds.map(d => [d.object, d.externalId || DEFAULT_EXTERNAL_ID_FIELD]), ); const refMap = this.buildReferenceMap(graph, externalIdByObject); @@ -289,7 +289,7 @@ export class SeedLoaderService implements ISeedLoaderService { // logical/validation failure. See framework#2678. const pendingInserts: Array<{ recordIndex: number; externalIdValue: string; record: Record }> = []; const opts = SeedLoaderService.SEED_OPTIONS as any; - const extIdOf = (rec: Record) => String(rec[externalId] ?? ''); + const extIdOf = (rec: Record) => this.externalIdKey(rec, externalId); // bulkWrite is at-least-once: a retry (or a mismatch-driven degradation) // may re-run a write whose prior attempt already committed. Guard against // duplicate seed rows by rechecking natural keys before re-inserting @@ -533,7 +533,7 @@ export class SeedLoaderService implements ISeedLoaderService { delete record[ref.field]; deferredUpdates.push({ objectName, - recordExternalId: String(record[externalId] ?? ''), + recordExternalId: this.externalIdKey(record, externalId), field: ref.field, targetObject: ref.targetObject, targetField: ref.targetField, @@ -601,7 +601,7 @@ export class SeedLoaderService implements ISeedLoaderService { else if (result.action === 'updated') updated++; else if (result.action === 'skipped') skipped++; - const externalIdValue = String(record[externalId] ?? ''); + const externalIdValue = this.externalIdKey(record, externalId); const internalId = result.id; if (externalIdValue && internalId) { insertedRecords.get(objectName)!.set(externalIdValue, String(internalId)); @@ -611,8 +611,8 @@ export class SeedLoaderService implements ISeedLoaderService { // Same cascade guard as the batched update path: the row may // already exist (rejected update), so keep its natural-key // mapping alive for downstream reference resolution. - const existingId = this.extractId(existingRecords?.get(String(record[externalId] ?? ''))); - const externalIdValue = String(record[externalId] ?? ''); + const externalIdValue = this.externalIdKey(record, externalId); + const existingId = this.extractId(existingRecords?.get(externalIdValue)); if (externalIdValue && existingId) { insertedRecords.get(objectName)!.set(externalIdValue, existingId); } @@ -623,7 +623,7 @@ export class SeedLoaderService implements ISeedLoaderService { } } else { const decision = this.decideWriteAction(record, mode, externalId, existingRecords); - const externalIdValue = String(record[externalId] ?? ''); + const externalIdValue = this.externalIdKey(record, externalId); if (decision.action === 'skip') { skipped++; @@ -661,7 +661,7 @@ export class SeedLoaderService implements ISeedLoaderService { } } else { // Dry-run: simulate insert tracking - const externalIdValue = String(record[externalId] ?? ''); + const externalIdValue = this.externalIdKey(record, externalId); if (externalIdValue) { insertedRecords.get(objectName)!.set(externalIdValue, `dry-run-id-${i}`); } @@ -900,11 +900,10 @@ export class SeedLoaderService implements ISeedLoaderService { objectName: string, record: Record, mode: string, - externalId: string, + externalId: string | string[], existingRecords?: Map, ): Promise<{ action: 'inserted' | 'updated' | 'skipped'; id?: string }> { - const externalIdValue = record[externalId]; - const existing = existingRecords?.get(String(externalIdValue ?? '')); + const existing = existingRecords?.get(this.externalIdKey(record, externalId)); const opts = SeedLoaderService.SEED_OPTIONS as any; switch (mode) { @@ -972,11 +971,10 @@ export class SeedLoaderService implements ISeedLoaderService { private decideWriteAction( record: Record, mode: string, - externalId: string, + externalId: string | string[], existingRecords?: Map, ): { action: 'insert' } | { action: 'update'; id: string } | { action: 'skip'; id?: string } { - const externalIdValue = record[externalId]; - const existing = existingRecords?.get(String(externalIdValue ?? '')); + const existing = existingRecords?.get(this.externalIdKey(record, externalId)); switch (mode) { case 'update': @@ -1051,19 +1049,21 @@ export class SeedLoaderService implements ISeedLoaderService { private buildWriteError( objectName: string, record: Record, - externalId: string, + externalId: string | string[], recordIndex: number, err: unknown, ): ReferenceResolutionError { const message = (err as { message?: unknown } | null)?.message ?? String(err); + const label = this.externalIdLabel(externalId); + const keyValue = this.externalIdKey(record, externalId); return { sourceObject: objectName, field: '(write)', targetObject: objectName, - targetField: externalId, - attemptedValue: record[externalId] ?? null, + targetField: label, + attemptedValue: keyValue || null, recordIndex, - message: `Failed to write ${objectName} record #${recordIndex} (${externalId}=${String(record[externalId] ?? '')}): ${message}`, + message: `Failed to write ${objectName} record #${recordIndex} (${label}=${keyValue}): ${message}`, }; } @@ -1201,7 +1201,7 @@ export class SeedLoaderService implements ISeedLoaderService { private buildReferenceMap( graph: ObjectDependencyGraph, - externalIdByObject?: Map, + externalIdByObject?: Map, ): Map { const map = new Map(); for (const node of graph.nodes) { @@ -1210,10 +1210,16 @@ export class SeedLoaderService implements ISeedLoaderService { // load carries one (copy-on-write — graph.nodes is part of the public // result and keeps the metadata-level 'name' default). Targets with // no dataset in this load (e.g. a user field → os_user) keep 'name'. + // + // Only a SINGLE-field externalId participates in reference resolution: + // a reference value is one natural-key string, so a composite-key + // target (a join table keyed by several fields) can't be matched by + // it — such targets keep the 'name' default (they're rarely, if ever, + // referenced by another object anyway). const references = externalIdByObject ? node.references.map(ref => { const datasetExternalId = externalIdByObject.get(ref.targetObject); - return datasetExternalId && datasetExternalId !== ref.targetField + return typeof datasetExternalId === 'string' && datasetExternalId !== ref.targetField ? { ...ref, targetField: datasetExternalId } : ref; }) @@ -1226,7 +1232,7 @@ export class SeedLoaderService implements ISeedLoaderService { private async loadExistingRecords( objectName: string, - externalId: string, + externalId: string | string[], organizationId?: string, ): Promise> { const map = new Map(); @@ -1242,7 +1248,7 @@ export class SeedLoaderService implements ISeedLoaderService { if (organizationId) findArgs.where = { organization_id: organizationId }; const records = await this.engine.find(objectName, findArgs as any); for (const record of records || []) { - const key = String(record[externalId] ?? ''); + const key = this.externalIdKey(record, externalId); if (key) { map.set(key, record); } @@ -1270,6 +1276,42 @@ export class SeedLoaderService implements ISeedLoaderService { return String(record.id || record._id || ''); } + /** + * Build the natural-key string for a record given the dataset's externalId. + * + * A single field name keys on that one column (the historical behavior). A + * COMPOSITE externalId — a list of field names, for objects with no single + * natural key such as a join / junction table (`['team', 'project']`) — + * joins the per-field values with a separator (`\u0000`) that cannot occur + * in a natural-key value, so `('a', 'b')` and `('a\0b', '')` never collide. + * + * Composite key fields are usually the join table's foreign keys; by the + * time this runs they have been RESOLVED to the parent's internal id on the + * incoming record, and existing DB rows already store that same id, so the + * two keys match on replay and the row dedupes (framework#3434). + * + * Returns '' when ANY component is absent — a partial key is not a usable + * uniqueness key, so callers fall back to inserting (this mirrors the + * single-field miss, which also yields ''). + */ + private externalIdKey(record: Record, externalId: string | string[]): string { + if (Array.isArray(externalId)) { + const parts: string[] = []; + for (const field of externalId) { + const value = record[field]; + if (value === undefined || value === null || value === '') return ''; + parts.push(String(value)); + } + return parts.join('\u0000'); + } + return String(record[externalId] ?? ''); + } + + /** Human-readable label for an externalId (single field, or `a+b` for a composite). */ + private externalIdLabel(externalId: string | string[]): string { + return Array.isArray(externalId) ? externalId.join('+') : externalId; + } + private buildEmptyResult(config: SeedLoaderConfig, durationMs: number): SeedLoaderResult { return { success: true, diff --git a/packages/spec/src/data/seed.test.ts b/packages/spec/src/data/seed.test.ts index 7e9c5e8038..e848f4f9fc 100644 --- a/packages/spec/src/data/seed.test.ts +++ b/packages/spec/src/data/seed.test.ts @@ -147,7 +147,7 @@ describe('SeedSchema', () => { it('should validate externalId field name', () => { const validExternalIds = ['name', 'code', 'external_id', 'username', 'slug']; - + validExternalIds.forEach(externalId => { expect(() => SeedSchema.parse({ object: 'test_object', @@ -157,6 +157,26 @@ describe('SeedSchema', () => { }); }); + it('should accept a composite externalId (join-table natural key, #3434)', () => { + const dataset = SeedSchema.parse({ + object: 'team_project_membership', + externalId: ['team', 'project'], + mode: 'ignore', + records: [{ team: 'Experience', project: 'Website Relaunch' }], + }); + + expect(dataset.externalId).toEqual(['team', 'project']); + expect(dataset.mode).toBe('ignore'); + }); + + it('should reject an empty composite externalId', () => { + expect(() => SeedSchema.parse({ + object: 'test_object', + externalId: [], + records: [], + })).toThrow(); + }); + it('should handle seed data use case', () => { const seedData = SeedSchema.parse({ object: 'country', diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts index 1832e7f2d5..13d4a1b23f 100644 --- a/packages/spec/src/data/seed.zod.ts +++ b/packages/spec/src/data/seed.zod.ts @@ -37,11 +37,23 @@ export const SeedSchema = lazySchema(() => z.object({ /** * Idempotency Key (The "Upsert" Key) - * The field used to check if a record already exists. + * The field (or fields) used to check if a record already exists. * Best Practice: Use a natural key like 'code', 'slug', 'username' or 'external_id'. * Standard: 'id' is rarely used for portable seed data — prefer natural keys. + * + * A single field name matches on that one column. A **composite** natural key + * — a list of field names — is required for objects with no single-field + * natural key, most commonly a **join / junction table** keyed by both of its + * foreign keys (e.g. `externalId: ['team', 'project']`). Without it such a + * dataset can only run `mode: 'insert'`, which re-inserts every row on each + * replay boot and duplicates the table (framework#3434). The reference fields + * are matched by their RESOLVED ids, so a composite of foreign keys dedupes + * correctly across restarts. */ - externalId: z.string().default('name').describe('Field match for uniqueness check'), + externalId: z + .union([z.string(), z.array(z.string()).min(1)]) + .default('name') + .describe('Field (or composite list of fields) matched for the uniqueness check'), /** * Import Strategy