Skip to content

Commit 1c31152

Browse files
committed
fix(seed-loader): support 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 returned `insert` unconditionally). The table duplicated on every restart: the showcase `showcase_project_membership` fixture (3 rows) grew 3 → 6 → 9. It stayed hidden until #3415 let the master-detail parents seed at all. - `SeedSchema.externalId` now accepts a list of field names (`externalId: ['team', 'project']`) as well as a single field name, declaring a composite natural key. Default stays 'name'. - `SeedLoaderService` builds the uniqueness key from all listed fields (joined with a NUL separator that can't occur in a natural-key value) via a single `externalIdKey` helper threaded through every key-building call site. 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. Single-field behavior is byte-for-byte unchanged. - 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. Tests: new composite-key replay test (insert-once then skip-all on replay, and a new pair still inserts while sharing rows don't block it) plus a spec schema test; existing single-field seed-loader suites unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JqZsDkKaCkpQGpJdEMEWDF
1 parent af5a224 commit 1c31152

6 files changed

Lines changed: 329 additions & 26 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/metadata-protocol": patch
4+
---
5+
6+
fix(seed-loader): support a composite `externalId` so join-table seeds dedupe on replay (#3434)
7+
8+
A junction / join table has no single-field natural key — the PAIR of its
9+
foreign keys is what's unique — so its seed could only run `mode: 'insert'`,
10+
which re-inserts every row on each replay boot with no existing-row check
11+
(`decideWriteAction`'s `insert` case returns `insert` unconditionally). The
12+
table duplicated on every restart: the showcase `showcase_project_membership`
13+
fixture (3 rows) grew 3 → 6 → 9. It was masked until #3415 let the master-detail
14+
parents seed at all.
15+
16+
- `SeedSchema.externalId` now accepts a **list** of field names
17+
(`externalId: ['team', 'project']`) in addition to a single field name,
18+
declaring a composite natural key. Default stays `'name'`.
19+
- `SeedLoaderService` builds the uniqueness key from all listed fields (joined
20+
with a `\u0000` separator that can't occur in a natural-key value). Reference
21+
key fields are compared by their RESOLVED parent ids — which the existing DB
22+
row already stores — so a composite of foreign keys matches across restarts.
23+
A partial key (any component absent) is treated as no key, falling back to
24+
insert, exactly as a missing single-field key already did.
25+
- A composite-key target does not participate in single-value reference
26+
resolution (a reference is one natural-key string), so such objects keep the
27+
`'name'` default when referenced by another dataset.
28+
29+
The showcase membership fixture switches to `mode: 'ignore'` +
30+
`externalId: ['team', 'project']`, so replay boots leave the three rows
31+
untouched instead of duplicating them.

examples/app-showcase/src/data/seed/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,14 @@ const products = defineSeed(Product, {
257257
],
258258
});
259259

260+
// A junction row has no single natural key — the (team, project) PAIR is what's
261+
// unique — so `mode: 'insert'` re-inserted all three rows on every replay boot,
262+
// duplicating the table 3 → 6 → 9 (framework#3434). A COMPOSITE externalId over
263+
// the two foreign keys lets `ignore` mode dedupe on replay: team/project are
264+
// matched by their resolved ids, which stay stable across restarts.
260265
const memberships = defineSeed(ProjectMembership, {
261-
mode: 'insert',
266+
mode: 'ignore',
267+
externalId: ['team', 'project'],
262268
records: [
263269
{ team: 'Experience', project: 'Website Relaunch', engagement: 'owner', allocation_percent: 80 },
264270
{ team: 'Platform', project: 'Data Platform', engagement: 'owner', allocation_percent: 100 },
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { SeedLoaderService } from './seed-loader';
5+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
6+
7+
/**
8+
* Composite externalId (framework#3434).
9+
*
10+
* A join / junction table has no single-field natural key — the PAIR of its
11+
* foreign keys is what's unique. Before composite externalId support, such a
12+
* dataset could only run `mode: 'insert'`, which re-inserts every row on each
13+
* replay boot and duplicates the table (the showcase memberships went 3→6→9).
14+
*
15+
* A composite `externalId: ['team', 'project']` + `mode: 'ignore'` dedupes the
16+
* rows across restarts, matching on the RESOLVED parent ids (a reference key
17+
* field is compared by the id it resolved to, which the existing DB row already
18+
* stores).
19+
*
20+
* Uses a faithful engine (filters `where`, mints ids) — the seed-loader.test.ts
21+
* mock ignores `where` and returns the whole table, which would mask replay
22+
* behavior. Mirrors the createFaithfulEngine helper in seed-loader-replay.test.ts.
23+
*/
24+
25+
function createLogger() {
26+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
27+
}
28+
29+
function createFaithfulEngine(): { engine: IDataEngine; store: Record<string, any[]> } {
30+
const store: Record<string, any[]> = {};
31+
let idCounter = 0;
32+
33+
const engine = {
34+
find: vi.fn(async (objectName: string, query?: any) => {
35+
let records = store[objectName] || [];
36+
if (query?.where) {
37+
records = records.filter((r) =>
38+
Object.entries(query.where).every(([k, v]) => r[k] === v),
39+
);
40+
}
41+
if (typeof query?.limit === 'number') records = records.slice(0, query.limit);
42+
return records;
43+
}),
44+
findOne: vi.fn(async (objectName: string, query?: any) => {
45+
const rows = await (engine.find as any)(objectName, { ...query, limit: 1 });
46+
return rows[0] ?? null;
47+
}),
48+
insert: vi.fn(async (objectName: string, data: any) => {
49+
if (!store[objectName]) store[objectName] = [];
50+
if (Array.isArray(data)) {
51+
const records = data.map((d) => ({ id: `gen-${++idCounter}`, ...d }));
52+
store[objectName].push(...records);
53+
return records;
54+
}
55+
const record = { id: `gen-${++idCounter}`, ...data };
56+
store[objectName].push(record);
57+
return record;
58+
}),
59+
update: vi.fn(async (objectName: string, data: any) => {
60+
const records = store[objectName] || [];
61+
const idx = records.findIndex((r) => r.id === data.id);
62+
if (idx >= 0) {
63+
records[idx] = { ...records[idx], ...data };
64+
return records[idx];
65+
}
66+
return data;
67+
}),
68+
delete: vi.fn(async () => ({ deleted: 1 })),
69+
count: vi.fn(async (objectName: string) => (store[objectName] || []).length),
70+
aggregate: vi.fn(async () => []),
71+
} as unknown as IDataEngine;
72+
73+
return { engine, store };
74+
}
75+
76+
function createMetadata(): IMetadataService {
77+
const objects: Record<string, any> = {
78+
demo_team: { name: 'demo_team', fields: { name: { type: 'text' } } },
79+
demo_project: { name: 'demo_project', fields: { name: { type: 'text' } } },
80+
// The join row: two required master_detail foreign keys, no single natural key.
81+
demo_membership: {
82+
name: 'demo_membership',
83+
fields: {
84+
team: { type: 'master_detail', reference: 'demo_team', required: true },
85+
project: { type: 'master_detail', reference: 'demo_project', required: true },
86+
engagement: { type: 'text' },
87+
},
88+
},
89+
};
90+
return {
91+
getObject: vi.fn(async (name: string) => objects[name]),
92+
listObjects: vi.fn(async () => Object.values(objects)),
93+
register: vi.fn(async () => {}),
94+
get: vi.fn(async (_t: string, name: string) => objects[name]),
95+
list: vi.fn(async () => []),
96+
unregister: vi.fn(async () => {}),
97+
exists: vi.fn(async () => false),
98+
listNames: vi.fn(async () => []),
99+
} as unknown as IMetadataService;
100+
}
101+
102+
// Mirrors the AppPlugin inline-seed config (defaultMode upsert, multiPass on).
103+
const CONFIG = {
104+
dryRun: false,
105+
haltOnError: false,
106+
multiPass: true,
107+
defaultMode: 'upsert',
108+
batchSize: 1000,
109+
transaction: false,
110+
} as any;
111+
112+
// Mirrors the showcase fixture: two teams, two projects, and three memberships
113+
// keyed by the (team, project) pair. Two rows share team 'Platform' and two
114+
// share project 'Website Relaunch', so NEITHER foreign key is unique alone —
115+
// only the pair is.
116+
const SEEDS = [
117+
{
118+
object: 'demo_team', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'],
119+
records: [{ name: 'Experience' }, { name: 'Platform' }],
120+
},
121+
{
122+
object: 'demo_project', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'],
123+
records: [{ name: 'Website Relaunch' }, { name: 'Data Platform' }],
124+
},
125+
{
126+
object: 'demo_membership', externalId: ['team', 'project'], mode: 'ignore', env: ['prod', 'dev', 'test'],
127+
records: [
128+
{ team: 'Experience', project: 'Website Relaunch', engagement: 'owner' },
129+
{ team: 'Platform', project: 'Data Platform', engagement: 'owner' },
130+
{ team: 'Platform', project: 'Website Relaunch', engagement: 'contributor' },
131+
],
132+
},
133+
] as any[];
134+
135+
describe('seed composite externalId — join-table dedupe on replay (#3434)', () => {
136+
it('inserts each (team, project) pair once and skips them all on replay (no 3→6→9)', async () => {
137+
const { engine, store } = createFaithfulEngine();
138+
const metadata = createMetadata();
139+
140+
// Boot #1 — fresh DB: all three pairs are new, all insert.
141+
const first = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
142+
expect(first.success).toBe(true);
143+
expect(store.demo_membership).toHaveLength(3);
144+
expect(first.results.find((r) => r.object === 'demo_membership')!.inserted).toBe(3);
145+
146+
// Foreign keys land as RESOLVED parent ids (not the raw natural-key strings).
147+
const teamId = (name: string) => store.demo_team.find((t) => t.name === name)!.id;
148+
const projectId = (name: string) => store.demo_project.find((p) => p.name === name)!.id;
149+
expect(store.demo_membership.map((m) => `${m.team}|${m.project}`).sort()).toEqual(
150+
[
151+
`${teamId('Experience')}|${projectId('Website Relaunch')}`,
152+
`${teamId('Platform')}|${projectId('Data Platform')}`,
153+
`${teamId('Platform')}|${projectId('Website Relaunch')}`,
154+
].sort(),
155+
);
156+
157+
// Boot #2 (dev-server restart): the composite key matches the existing rows
158+
// by resolved id, so all three skip — the table stays at 3, not 6.
159+
const second = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
160+
expect(second.success).toBe(true);
161+
expect(store.demo_membership).toHaveLength(3);
162+
const replay = second.results.find((r) => r.object === 'demo_membership')!;
163+
expect(replay.inserted).toBe(0);
164+
expect(replay.skipped).toBe(3);
165+
166+
// Boot #3 — still 3 (the historical bug grew the table on every boot).
167+
await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
168+
expect(store.demo_membership).toHaveLength(3);
169+
});
170+
171+
it('distinguishes pairs: a genuinely new (team, project) still inserts, sharing rows do not block it', async () => {
172+
const { engine, store } = createFaithfulEngine();
173+
const metadata = createMetadata();
174+
175+
await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: SEEDS, config: CONFIG });
176+
expect(store.demo_membership).toHaveLength(3);
177+
178+
// A 4th membership reusing an EXISTING team and an EXISTING project, but a
179+
// NEW pairing (Experience × Data Platform). Composite dedupe keys on the
180+
// full pair, so this must insert — not skip because the team or the project
181+
// already appears in some other row.
182+
const grown = structuredClone(SEEDS);
183+
grown[2].records.push({ team: 'Experience', project: 'Data Platform', engagement: 'reviewer' });
184+
185+
const res = await new SeedLoaderService(engine, metadata, createLogger()).load({ seeds: grown, config: CONFIG });
186+
expect(res.success).toBe(true);
187+
expect(store.demo_membership).toHaveLength(4);
188+
const membership = res.results.find((r) => r.object === 'demo_membership')!;
189+
expect(membership.inserted).toBe(1); // only the new pair
190+
expect(membership.skipped).toBe(3); // the original three, unchanged
191+
});
192+
});

0 commit comments

Comments
 (0)