// 0007 樹狀 record 模型遷移驗收(v7 定稿,leo 2026-08-15 confirm)。 // // 驗收條件(派工單指定,逐條對應): // 1. 既有資料一筆都不能掉:entry_values 每列 → 一條格子關係列;每筆 record → 身分 entry // 2. 冪等:同一份遷移語句跑兩次結果相同——用最嚴苛的那條套用路徑模擬 // (安裝器逐句重放:剝註解、依分號切句、逐句執行、容錯 duplicate column) // 3. value entries 一列都不動(id 穩定 ⇒ 向量索引不失效) // 4. 孤兒偵測有答案:新模型的孤兒=指標指向不存在 id 的關係列(LEFT JOIN 斷鏈掃描) // // 測試策略比照 execution-log.test.ts:真 SQLite(node:sqlite)套 migrations 原檔。 import { describe, it, expect } from 'vitest'; import { DatabaseSync } from 'node:sqlite'; import { readFileSync } from 'node:fs'; import { scanRelationOrphans } from '../src/actions/relation-orphans'; import { getRecord, searchByTemplate } from '../src/actions/record-crud'; // ── 安裝器逐句重放模式(products/arcrun-rag installer 的實際手法)────────────── function splitStatements(sql: string): string[] { return sql .split('\n') .map((l) => l.replace(/--.*$/, '')) .join('\n') .split(';') .map((s) => s.trim()) .filter((s) => s.length > 0); } function applyTolerant(raw: DatabaseSync, file: string): string[] { const sql = readFileSync(new URL(`../migrations/${file}`, import.meta.url), 'utf8'); const errors: string[] = []; for (const stmt of splitStatements(sql)) { try { raw.exec(stmt); // kbdb-sql-ok:測試治具逐句重放 migration 原檔(模擬安裝器路徑) } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用 errors.push(`${stmt.slice(0, 50)}… → ${msg}`); } } return errors; } // ── 舊世界資料庫(0001 schema + entry_values 真資料)──────────────────────── function legacyDB(): DatabaseSync { const raw = new DatabaseSync(':memory:'); raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 migration 原檔 raw.exec(`INSERT OR IGNORE INTO templates (id,name,description,slots_json,created_by) VALUES ('tpl-triplet','triplet','t','["subject","predicate","object","library"]','system')`); // kbdb-sql-ok:測試治具 seed const insE = raw.prepare(`INSERT INTO entries (id,content,entry_type,owner_id) VALUES (?,?,?,?)`); // kbdb-sql-ok:測試治具 seed const insEV = raw.prepare(`INSERT INTO entry_values (id,record_id,template_id,slot_name,entry_id) VALUES (?,?,?,?,?)`); // kbdb-sql-ok:測試治具 seed(舊表在 0007 前存在) for (let r = 0; r < 40; r++) { for (const s of ['subject', 'predicate', 'object', 'library']) { insE.run(`e_${r}_${s}`, `${s}-${r}`, 'value', 'leo'); insEV.run(`ev_${r}_${s}`, `rec_${r}`, 'tpl-triplet', s, `e_${r}_${s}`); } } // 一筆「block 即 record」(library_map 慣例):record_id 是既有 block entry 的 id insE.run('blk_map', '地圖 block', 'block', 'leo'); insE.run('e_map_lib', 'general', 'value', 'leo'); insEV.run('ev_map_lib', 'blk_map', 'tpl-triplet', 'library', 'e_map_lib'); return raw; } const count = (raw: DatabaseSync, sql: string): number => (raw.prepare(sql).get() as { n: number }).n; // kbdb-sql-ok:測試治具讀回斷言 function snapshot(raw: DatabaseSync) { return { total: count(raw, 'SELECT COUNT(*) AS n FROM entries'), cells: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE src_id IS NOT NULL AND rel_id NOT IN ('sys_belongs','sys_field_of')"), belongs: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE rel_id = 'sys_belongs'"), identities: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'record'"), values: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'value'"), evTable: count(raw, "SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='entry_values'"), }; } // node:sqlite → D1 最小 adapter(同 execution-log.test.ts 手法) function asD1(raw: DatabaseSync): D1Database { function stmt(sql: string, params: unknown[]) { const s = { bind(...args: unknown[]) { return stmt(sql, args); }, async all() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok:測試治具 async first() { return (raw.prepare(sql).get(...(params as never[])) ?? null) as T | null; }, // kbdb-sql-ok:測試治具 async run() { const r = raw.prepare(sql).run(...(params as never[])); return { success: true, meta: { changes: r.changes } }; }, // kbdb-sql-ok:測試治具 }; return s; } return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; } describe('0007 — 資料一筆都不能掉(entry_values → 關係列)', () => { it('每格一條關係列、每 record 一顆身分、value entries 一列不動、舊表拆掉', () => { const raw = legacyDB(); const evBefore = count(raw, 'SELECT COUNT(*) AS n FROM entry_values'); const valuesBefore = count(raw, "SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'value'"); const valueIdsBefore = raw.prepare("SELECT id FROM entries WHERE entry_type='value' ORDER BY id").all(); // kbdb-sql-ok:測試治具 const errors = applyTolerant(raw, '0007_tree_record_model.sql'); expect(errors).toEqual([]); const s = snapshot(raw); expect(s.cells).toBe(evBefore); // 每格一條關係列,一列不漏 expect(s.values).toBe(valuesBefore); // value entries 沒被動 expect(s.evTable).toBe(0); // entry_values 不存在=遷移完成的證明(v7 §7) // 41 筆 record:40 顆裸身分(entry_type='record')+ 1 顆 block 即 record(不另建身分) expect(s.identities).toBe(40); expect(count(raw, "SELECT COUNT(*) AS n FROM entries WHERE id = 'blk_map' AND entry_type = 'block'")).toBe(1); // 歸屬:41 筆 record + 2 張 sheet(tpl-triplet 與 0001 自帶 seed 的 tpl-recipe-stat,各一條 ─屬於→ sys_root) expect(s.belongs).toBe(43); // id 穩定(向量索引不失效):value entry 的 id 集合前後逐字相同 const valueIdsAfter = raw.prepare("SELECT id FROM entries WHERE entry_type='value' ORDER BY id").all(); // kbdb-sql-ok:測試治具 expect(valueIdsAfter).toEqual(valueIdsBefore); }); it('遷移後讀端讀得回同樣的資料(getRecord/searchByTemplate 走關係列)', async () => { const raw = legacyDB(); applyTolerant(raw, '0007_tree_record_model.sql'); const db = asD1(raw); const rec = await getRecord(db, 'rec_7'); expect(rec).not.toBeNull(); expect(rec!.values).toEqual({ subject: 'subject-7', predicate: 'predicate-7', object: 'object-7', library: 'library-7' }); expect(rec!.template_id).toBe('tpl-triplet'); expect(rec!.owner_id).toBe('leo'); const all = await searchByTemplate(db, 'triplet', 'leo', 500); expect(all.length).toBe(41); }); }); describe('0007 — 冪等(同一份語句跑兩次結果相同;三條套用路徑最嚴苛的一條)', () => { it('第二次逐句重放:零錯誤、快照逐字相等', () => { const raw = legacyDB(); const e1 = applyTolerant(raw, '0007_tree_record_model.sql'); const s1 = snapshot(raw); const e2 = applyTolerant(raw, '0007_tree_record_model.sql'); const s2 = snapshot(raw); expect(e1).toEqual([]); expect(e2).toEqual([]); expect(s2).toEqual(s1); }); }); describe('0007 配套 — 孤兒偵測(v7 §5:新模型的孤兒要有答案,不能默認)', () => { it('乾淨遷移後零孤兒;人為斷鏈後掃得到、指得出斷在哪一端', async () => { const raw = legacyDB(); applyTolerant(raw, '0007_tree_record_model.sql'); const db = asD1(raw); expect((await scanRelationOrphans(db)).count).toBe(0); // 人為製造斷鏈(模擬繞牆直刪——正是 2026-06-24 事故那類殘局) raw.exec("DELETE FROM entries WHERE id = 'e_3_object'"); // kbdb-sql-ok:測試治具刻意製造孤兒 const report = await scanRelationOrphans(db); expect(report.count).toBe(1); expect(report.orphans[0]).toEqual({ relation_id: 'relv_ev_3_object', role: 'dst', missing_id: 'e_3_object' }); }); });