Skip to content

Commit abceb0d

Browse files
os-zhuangclaude
andauthored
fix(seed-loader): support composite externalId so join-table seeds dedupe on replay (#3442)
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 (showcase memberships grew 3→6→9). SeedSchema.externalId now accepts a composite list of field names; the loader keys existing-row matching on the resolved foreign-key ids so join-table seeds dedupe across restarts. Single-field behavior is unchanged. The showcase membership fixture switches to mode:'ignore' + externalId:['team','project']. Closes #3434 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JqZsDkKaCkpQGpJdEMEWDF
1 parent 71f76e1 commit abceb0d

9 files changed

Lines changed: 332 additions & 29 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.

content/docs/references/data/seed-loader.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ Seed loader request with datasets and configuration
183183

184184
| Property | Type | Required | Description |
185185
| :--- | :--- | :--- | :--- |
186-
| **seeds** | `{ object: string; externalId: string; mode: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` || Seeds to load |
186+
| **seeds** | `{ object: string; externalId: string \| string[]; mode: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` || Seeds to load |
187187
| **config** | `{ dryRun: boolean; haltOnError: boolean; multiPass: boolean; defaultMode: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; … }` || Loader configuration |
188188

189189

content/docs/references/data/seed.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const result = Seed.parse(data);
3232
| Property | Type | Required | Description |
3333
| :--- | :--- | :--- | :--- |
3434
| **object** | `string` || Target Object Name |
35-
| **externalId** | `string` || Field match for uniqueness check |
35+
| **externalId** | `string \| string[]` || Field (or composite list of fields) matched for the uniqueness check |
3636
| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` || Conflict resolution strategy |
3737
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` || Applicable environments |
3838
| **records** | `Record<string, any>[]` || Data records |

content/docs/references/kernel/manifest.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const result = Manifest.parse(data);
6161
| **dependencies** | `Record<string, string>` | optional | Package dependencies |
6262
| **configuration** | `{ title?: string; properties: Record<string, { type: Enum<'string' \| 'number' \| 'boolean' \| 'array' \| 'object'>; default?: any; description?: string; required?: boolean; … }> }` | optional | Plugin configuration settings |
6363
| **contributes** | `{ kinds?: { id: string; globs: string[]; description?: string }[]; events?: string[]; menus?: Record<string, { id: string; label: string; command?: string }[]>; themes?: { id: string; label: string; path: string }[]; … }` | optional | Platform contributions |
64-
| **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) |
64+
| **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) |
6565
| **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 |
6666
| **extensions** | `Record<string, any>` | optional | Extension points and contributions |
6767
| **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 |

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)