|
| 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