Files
Arcrun/kbdb/tests/tree-record-migration.test.ts
T
uncle6me-web ceb7638d74 feat(kbdb): 樹狀 record 模型第一刀——record 有身分、關係是唯一機制、entry_values 拆表(v7 定稿實作)
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。
模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」

- 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數
  (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+
  每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列
  (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values
  (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。
- record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留,
  驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。
- entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry
  接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。
- 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列,
  LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀),
  GET /maintenance/relation-orphans 唯讀巡檢。
- cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS,
  整檔送 /query 會在重跑時假紅)。
- 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描;
  釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。

遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型
與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:34:48 +08:00

155 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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:真 SQLitenode: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<T>() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok:測試治具
async first<T>() { 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 筆 record40 顆裸身分(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 張 sheettpl-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('遷移後讀端讀得回同樣的資料(getRecordsearchByTemplate 走關係列)', 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' });
});
});