Skip to content

Commit 4eb9299

Browse files
committed
test(runtime): pin flat-bundle seed collection (#15262)
RED: a bundle with no `manifest` key and a top-level `data` array has every seed dataset collected twice, and a `mode: 'insert'` dataset applied twice per boot. The third case is the anti-vacuity control -- a genuinely distinct `manifest.data` must still be collected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
1 parent 954cb0b commit 4eb9299

1 file changed

Lines changed: 112 additions & 0 deletions

File tree

‎packages/runtime/src/app-plugin.seed.test.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
44
import { AppPlugin } from './app-plugin';
5+
import { readSeedDatasets } from './seed-datasets.js';
56
import { readSeedSettlement } from './seed-settlement.js';
67
import type { PluginContext } from '@objectstack/core';
78

@@ -234,3 +235,114 @@ describe('AppPlugin inline-seed settle signal (app:seeded, #2996)', () => {
234235
});
235236
});
236237
});
238+
239+
/**
240+
* #15262 — a FLAT bundle (manifest fields written directly on the bundle, no
241+
* `manifest:` key) had every seed dataset collected TWICE.
242+
*
243+
* `start()` reads seed data from two locations: the top-level `data` field and
244+
* the legacy `manifest.data`. The legacy read resolves its base as
245+
* `this.bundle.manifest || this.bundle`, so on a flat bundle it re-reads the
246+
* very array the top-level read just contributed. `mergeSeedDatasets` is a
247+
* plain `push` with no de-duplication, so both copies reach the shared
248+
* `seed-datasets` registry AND the inline loader — for a `mode: 'insert'`
249+
* dataset that is the row applied twice per boot, not merely doubled work.
250+
*
251+
* The sibling two-location collector for `translations` (`loadTranslations`)
252+
* has always carried the reference guard that makes the same read safe. These
253+
* tests pin BOTH halves of that guard: the flat bundle collects once, and a
254+
* bundle whose `manifest.data` is a genuinely DIFFERENT array still collects
255+
* both — a fix that simply deleted the legacy read would pass the first
256+
* assertion and fail the second.
257+
*/
258+
describe('AppPlugin flat-bundle seed collection (#15262)', () => {
259+
const OLD_BUDGET = process.env.OS_INLINE_SEED_BUDGET_MS;
260+
const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED;
261+
262+
let insert: ReturnType<typeof vi.fn>;
263+
264+
/** A context with a real service map, so `seed-datasets` can be read back. */
265+
const makeContext = (): PluginContext => {
266+
const services = new Map<string, unknown>();
267+
return {
268+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
269+
registerService: vi.fn((name: string, svc: unknown) => {
270+
if (services.has(name)) throw new Error(`service '${name}' already registered`);
271+
services.set(name, svc);
272+
}),
273+
getService: vi.fn((name: string) => {
274+
if (name === 'objectql') return { insert };
275+
if (services.has(name)) return services.get(name);
276+
return undefined; // `metadata` absent → basic-insert fallback
277+
}),
278+
getServices: vi.fn(() => new Map()),
279+
hook: vi.fn(),
280+
trigger: vi.fn(),
281+
} as unknown as PluginContext;
282+
};
283+
284+
/** The shape the card names: NO `manifest` key, a top-level `data` array. */
285+
const flatBundle = () => ({
286+
id: 'com.test.flat-seed',
287+
name: 'flat-seed',
288+
data: [
289+
{
290+
object: 'sys_user',
291+
mode: 'insert',
292+
records: [{ id: 'flat_u1', name: 'Flat One' }],
293+
},
294+
],
295+
});
296+
297+
beforeEach(() => {
298+
delete process.env.OS_MULTI_ORG_ENABLED;
299+
process.env.OS_INLINE_SEED_BUDGET_MS = '8000';
300+
insert = vi.fn(async () => undefined);
301+
});
302+
303+
afterEach(() => {
304+
if (OLD_BUDGET === undefined) delete process.env.OS_INLINE_SEED_BUDGET_MS;
305+
else process.env.OS_INLINE_SEED_BUDGET_MS = OLD_BUDGET;
306+
if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
307+
else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI;
308+
});
309+
310+
it('collects a flat bundle seed dataset ONCE, not once per read location', async () => {
311+
const ctx = makeContext();
312+
313+
await new AppPlugin(flatBundle()).start(ctx);
314+
315+
expect(readSeedDatasets(ctx)).toHaveLength(1);
316+
});
317+
318+
it('applies a mode:insert record ONCE per boot on a flat bundle', async () => {
319+
const ctx = makeContext();
320+
321+
await new AppPlugin(flatBundle()).start(ctx);
322+
323+
// The row consequence, not the collection count: the doubled dataset
324+
// was applied twice by the inline seed — the #3434 end state reached
325+
// by a different route.
326+
expect(insert).toHaveBeenCalledTimes(1);
327+
expect(insert).toHaveBeenCalledWith('sys_user', { id: 'flat_u1', name: 'Flat One' }, expect.anything());
328+
});
329+
330+
it('still collects BOTH sources when manifest.data is a genuinely different array', async () => {
331+
const ctx = makeContext();
332+
const bundle = {
333+
manifest: {
334+
id: 'com.test.nested-seed',
335+
data: [{ object: 'sys_user', mode: 'insert', records: [{ id: 'legacy_u1' }] }],
336+
},
337+
data: [{ object: 'sys_user', mode: 'insert', records: [{ id: 'top_u1' }] }],
338+
};
339+
340+
await new AppPlugin(bundle).start(ctx);
341+
342+
// Anti-vacuity: the legacy fallback is GUARDED, not removed.
343+
expect(readSeedDatasets(ctx)).toHaveLength(2);
344+
expect(insert).toHaveBeenCalledTimes(2);
345+
expect(insert).toHaveBeenCalledWith('sys_user', { id: 'top_u1' }, expect.anything());
346+
expect(insert).toHaveBeenCalledWith('sys_user', { id: 'legacy_u1' }, expect.anything());
347+
});
348+
});

0 commit comments

Comments
 (0)