Skip to content

Commit ac65bc5

Browse files
claudeos-zhuang
authored andcommitted
test(seed-loader): pin multi-value lookup seeds on a REAL SqlDriver (#3911)
A `multiple: true` lookup lands in a JSON column, so the resolved id ARRAY takes a serialize → SQLite TEXT → parse round-trip the in-memory mock never performs — and that round-trip is exactly what the replay comparison reads back on the next boot. The unit suite proves the resolution; only the real driver can prove the stored shape, and that a seeded association is stable across restarts instead of being rewritten or duplicated. Three cases on better-sqlite3 + the real ObjectQL engine: the array round-trips as an array of ids, a second load over the same database skips all three rows (no `updated_at` churn, no duplicate book), and natural keys resolve against authors that already existed in the database. All three fail against pre-fix `metadata-protocol` (verified by rebuilding its dist from the reverted source — a source-only revert proves nothing here, the runtime tests resolve that package from `dist`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gusro3gdGv4wgbBnaFy9ah
1 parent a0dbb25 commit ac65bc5

1 file changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Real-driver regression for multi-value lookup seeds (framework#3911).
4+
//
5+
// A `multiple: true` lookup is stored in a JSON column, so the resolved id
6+
// ARRAY takes a serialize → SQLite TEXT → parse round-trip that an in-memory
7+
// mock driver never performs. That round-trip is exactly what the loader's
8+
// replay comparison reads back on the next boot, so it decides whether a
9+
// seeded association is stable or rewritten (or duplicated) on every restart.
10+
// The unit suite in metadata-protocol proves the resolution; only the real
11+
// SqlDriver can prove the stored shape and the replay.
12+
13+
import { describe, it, expect, afterEach } from 'vitest';
14+
import { mkdtempSync, rmSync } from 'node:fs';
15+
import { tmpdir } from 'node:os';
16+
import { join } from 'node:path';
17+
import { ObjectQL } from '@objectstack/objectql';
18+
import { SeedLoaderService } from '@objectstack/metadata-protocol';
19+
import { SqlDriver } from '@objectstack/driver-sql';
20+
21+
const AUTHOR = {
22+
name: 'author',
23+
fields: { name: { type: 'text', required: true } },
24+
};
25+
const BOOK = {
26+
name: 'book',
27+
fields: {
28+
name: { type: 'text', required: true },
29+
authors: { type: 'lookup', reference: 'author', multiple: true },
30+
reviewer: { type: 'lookup', reference: 'author' },
31+
},
32+
};
33+
34+
const SEED_CONFIG = {
35+
dryRun: false, haltOnError: false, multiPass: true,
36+
defaultMode: 'upsert', batchSize: 1000, transaction: false,
37+
} as any;
38+
const logger = { info() {}, warn() {}, error() {}, debug() {} };
39+
40+
function metadataFor(objects: any[]) {
41+
const byName = new Map(objects.map((o) => [o.name, o]));
42+
return {
43+
getObject: async (name: string) => byName.get(name),
44+
listObjects: async () => objects,
45+
register: async () => {}, get: async (_t: string, n: string) => byName.get(n),
46+
list: async () => [], unregister: async () => {}, exists: async () => false, listNames: async () => [],
47+
} as any;
48+
}
49+
50+
/** The issue's minimal repro, verbatim. */
51+
const SEEDS = [
52+
{
53+
object: 'author', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'],
54+
records: [{ name: 'Alice' }, { name: 'Bob' }],
55+
},
56+
{
57+
object: 'book', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'],
58+
records: [{ name: 'Refactoring', authors: ['Alice', 'Bob'] }],
59+
},
60+
];
61+
62+
describe('multi-value lookup seeds on a REAL SqlDriver (framework#3911)', () => {
63+
let dir: string | null = null;
64+
let engine: ObjectQL | null = null;
65+
66+
afterEach(async () => {
67+
try { await engine?.destroy(); } catch { /* noop */ }
68+
engine = null;
69+
if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; }
70+
});
71+
72+
async function boot(objects: any[]) {
73+
dir = mkdtempSync(join(tmpdir(), 'os-seed-multi-real-'));
74+
const driver = new SqlDriver({
75+
client: 'better-sqlite3',
76+
connection: { filename: join(dir, 'data.sqlite') },
77+
useNullAsDefault: true,
78+
});
79+
await driver.initObjects(objects); // real tables, real JSON column
80+
engine = new ObjectQL();
81+
engine.registerDriver(driver, true);
82+
await engine.init();
83+
for (const o of objects) engine.registry.registerObject(o as any);
84+
return engine;
85+
}
86+
87+
it('stores the resolved id ARRAY and reads it back through the JSON column', async () => {
88+
const e = await boot([AUTHOR, BOOK]);
89+
const loader = new SeedLoaderService(e as any, metadataFor([AUTHOR, BOOK]), logger);
90+
91+
const result = await loader.load({ seeds: SEEDS as any, config: SEED_CONFIG });
92+
expect(result.success).toBe(true);
93+
expect(result.summary.totalErrored).toBe(0);
94+
95+
const authors: any[] = await e.find('author', {});
96+
const alice = authors.find((r) => r.name === 'Alice')!;
97+
const bob = authors.find((r) => r.name === 'Bob')!;
98+
const book: any = (await e.find('book', {}))[0];
99+
100+
// The bug: `authors` was dropped entirely and the row landed without it.
101+
expect(Array.isArray(book.authors)).toBe(true);
102+
expect(book.authors).toEqual([alice.id, bob.id]);
103+
});
104+
105+
it('replays idempotently — the array survives a second boot with no churn or duplicates', async () => {
106+
const e = await boot([AUTHOR, BOOK]);
107+
const loader = new SeedLoaderService(e as any, metadataFor([AUTHOR, BOOK]), logger);
108+
109+
await loader.load({ seeds: SEEDS as any, config: SEED_CONFIG });
110+
const first: any = (await e.find('book', {}))[0];
111+
112+
// Second boot over the same database — the replay path compares the freshly
113+
// resolved id array against the JSON-parsed stored one.
114+
const replay = await loader.load({ seeds: SEEDS as any, config: SEED_CONFIG });
115+
116+
expect(replay.success).toBe(true);
117+
expect(replay.summary.totalUpdated).toBe(0); // no updated_at churn
118+
expect(replay.summary.totalSkipped).toBe(3); // 2 authors + 1 book
119+
expect(await e.count('book', {})).toBe(1); // no duplicate row
120+
121+
const after: any = (await e.find('book', {}))[0];
122+
expect(after.id).toBe(first.id);
123+
expect(after.authors).toEqual(first.authors);
124+
});
125+
126+
it('resolves against authors that already exist in the database', async () => {
127+
const e = await boot([AUTHOR, BOOK]);
128+
const alice = await e.insert('author', { name: 'Alice' });
129+
const bob = await e.insert('author', { name: 'Bob' });
130+
131+
const loader = new SeedLoaderService(e as any, metadataFor([AUTHOR, BOOK]), logger);
132+
const result = await loader.load({ seeds: [SEEDS[1]] as any, config: SEED_CONFIG });
133+
134+
expect(result.success).toBe(true);
135+
const book: any = (await e.find('book', {}))[0];
136+
expect(book.authors).toEqual([alice.id, bob.id]);
137+
});
138+
});

0 commit comments

Comments
 (0)