ceb7638d74
規格: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>
155 lines
8.4 KiB
TypeScript
155 lines
8.4 KiB
TypeScript
// 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<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 筆 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' });
|
||
});
|
||
});
|