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>
200 lines
12 KiB
TypeScript
200 lines
12 KiB
TypeScript
// 三元組 library 補標 — 源頭順序 + 存量補標 + 冪等(2026-08-11,leo 貼 wiki 卡「三元組要恢復」)
|
||
//
|
||
// 背景(system-dev/wiki/ops-facts.md「三元組在 KBDB 有兩代儲存形式」段,2026-08-11 實測):
|
||
// 1,633 筆新式三元組只有 171 筆填了 library slot,地圖(GET /map)因此幾乎看不到資料。
|
||
// 根因是**寫入順序**:createRecord 只會替 template.slots_json 裡「已宣告」的 slot 建 entry_value
|
||
// (見 src/actions/record-crud.ts createRecord:`for (const slot of slots) { if (!(slot in
|
||
// input.values)) continue }`——注意是遍歷 template 既有 slots,不是遍歷 caller 傳的 values)。
|
||
// 若呼叫端在 template 還沒有 'library' slot 時就送出 library 值,那個值會被**靜默丟棄**、
|
||
// 不報錯——這正是「查得到 171 筆」的來源:只有「已經跑過一次 ensureTripletLibrarySlot/recompute
|
||
// 之後」的批次,library 才真的落地。
|
||
//
|
||
// 本檔驗三件事(對應 leo 交辦的三個「要驗的」):
|
||
// 1. 源頭:ensure-slot 必須在 write 之前,不能事後補(重現+證明順序才是正解)
|
||
// 2. 存量:對一批缺 library 的舊 triplet 補標,前後地圖輸出對照
|
||
// 3. 不會重複做:同一批跑兩次,第二次 touch 0 筆
|
||
//
|
||
// 測試手法沿 library-map.test.ts 慣例:真 node:sqlite(Node ≥22.5 內建,零新依賴)跑
|
||
// migrations 原檔,本檔只是這顆記憶體內測試替身的操作者——不是碰 KBDB 的正式 D1,
|
||
// 與正式資料庫零關聯(D38 的牆管的是「牆外程式碼碰 KBDB 的真 D1」,這裡是牆內邏輯的
|
||
// 白盒測試替身,kbdb-api-wall-guard 對 *.test.ts 路徑做字面 grep 會誤判,行尾標
|
||
// kbdb-sql-ok 是這個誤判的既定逃生艙,見 hook 說明「Genuine exception」)。
|
||
import { describe, it, expect } from 'vitest';
|
||
import { DatabaseSync } from 'node:sqlite';
|
||
import { readFileSync } from 'node:fs';
|
||
import {
|
||
recomputeLibraryMap,
|
||
ensureTripletLibrarySlot,
|
||
ensureFreshLibraryMaps,
|
||
listLibraryMaps,
|
||
} from '../src/actions/library-map';
|
||
import { createTemplate, createRecord, updateRecord, getRecord, searchByTemplate } from '../src/actions/record-crud';
|
||
|
||
function makeSqliteD1(): D1Database {
|
||
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok: 記憶體測試替身,非真 KBDB D1
|
||
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 灌測試替身 schema,非真 D1
|
||
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 同上
|
||
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
|
||
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 T[] }; }, // kbdb-sql-ok: 記憶體測試替身
|
||
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 記憶體測試替身
|
||
async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 記憶體測試替身
|
||
};
|
||
return s;
|
||
}
|
||
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||
}
|
||
|
||
// prod 實際 triplet template 的 slots(library-map.test.ts 同款常數,2026-07-19 kbdb_list_templates
|
||
// 核實)——注意:沒有 library。用它模擬「template 還沒被任何 recompute 摸過」的乾淨起點。
|
||
const PROD_TRIPLET_SLOTS = [
|
||
'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json',
|
||
'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by',
|
||
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
|
||
];
|
||
|
||
async function seedTripletTemplate(db: D1Database): Promise<void> {
|
||
await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
|
||
}
|
||
|
||
// 對齊 design.md 既有 fallback 語意(source_prefix 參數)的同一條規則:
|
||
// library = source_uri 在 '@' 之前的那段(scheme:owner/repo)。這就是本檔+報告裡建議
|
||
// kbdb-graph-plugin 在 write 時就该套用的推導規則(見報告,本檔只證明「規則正確、
|
||
// 順序對了就能用」,不代表已經改了 kbdb-graph-plugin 的程式碼——那是另一個 repo)。
|
||
function deriveLibrary(sourceUri: string): string {
|
||
const at = sourceUri.indexOf('@');
|
||
return at > 0 ? sourceUri.slice(0, at) : sourceUri;
|
||
}
|
||
|
||
describe('源頭順序 — ensure-slot 必須在 write 之前,事後補救不了已寫的那筆', () => {
|
||
it('重現:template 尚無 library slot 時寫入 → library 值被靜默丟棄(不是報錯,是消失)', async () => {
|
||
const db = makeSqliteD1();
|
||
await seedTripletTemplate(db);
|
||
|
||
const rec = await createRecord(db, {
|
||
template: 'triplet',
|
||
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
|
||
owner_id: 'leo',
|
||
});
|
||
const stored = await getRecord(db, rec.record_id);
|
||
// 關鍵斷言:library 完全沒落地,不是空字串、是 undefined(key 都不存在)。
|
||
expect(stored!.values.library).toBeUndefined();
|
||
expect(stored!.values.source_uri).toBe('gitea:Leo/kb@a.md'); // 其他 slot 正常落地,只有未宣告的 slot 消失
|
||
});
|
||
|
||
it('正解:先 ensureTripletLibrarySlot() 補上 slot,再寫 → library 值正常落地', async () => {
|
||
const db = makeSqliteD1();
|
||
await seedTripletTemplate(db);
|
||
|
||
const added = await ensureTripletLibrarySlot(db, 'triplet');
|
||
expect(added).toBe(true); // 第一次呼叫確實補了 slot
|
||
|
||
const rec = await createRecord(db, {
|
||
template: 'triplet',
|
||
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
|
||
owner_id: 'leo',
|
||
});
|
||
const stored = await getRecord(db, rec.record_id);
|
||
expect(stored!.values.library).toBe('gitea:Leo/kb');
|
||
|
||
// 冪等:對已有 slot 的 template 再呼叫一次 → false(不重複加),不影響既有資料。
|
||
const addedAgain = await ensureTripletLibrarySlot(db, 'triplet');
|
||
expect(addedAgain).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('存量補標 — 對缺 library 的舊 triplet 補標,地圖輸出前後對照', () => {
|
||
it('補標前:地圖看不到任何庫(triplet 全部因缺 library 而未被地圖聚合);補標後:庫名正確出現', async () => {
|
||
const db = makeSqliteD1();
|
||
await seedTripletTemplate(db);
|
||
// 模擬現況:三筆舊 triplet,寫入時 template 還沒有 library slot(如實重現存量現況),
|
||
// 只帶了 source_uri(之後補標要靠它反推 library)。
|
||
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', status: 'active' }, owner_id: 'leo' });
|
||
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md', status: 'active' }, owner_id: 'leo' });
|
||
const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'github:uncle6-me/notes@c.md', status: 'active' }, owner_id: 'leo' });
|
||
|
||
// 讀端自動核對重算(M3 收尾機制):這時三筆都缺 library 值。liveTripletCountsByLibrary
|
||
// 把「缺值」COALESCE 成 'general' 桶(供 staleness 判斷),但 recomputeLibraryMap 的
|
||
// libCond 是 `t.library = 'general'` 精確比對——缺值在底層是 NULL 不是字面 'general',
|
||
// 比對不中,實際聚合出 triplet_count:0。**這是本次順手發現的另一個小落差**(live 計數桶
|
||
// 與 recompute 精確比對的『general』語意沒對齊,導致這桶每次讀都判 stale、白重算,
|
||
// 但至少不會謊報數字)——不在本次任務範圍內(leo 問的是兩代儲存形式的可見性,不是這個
|
||
// fallback 桶的效能問題),本測試如實記錄現況,不假裝它是 0。
|
||
await ensureFreshLibraryMaps(db, 'leo');
|
||
const before = await listLibraryMaps(db, 'leo');
|
||
expect(before.length).toBe(1);
|
||
expect(before[0].library).toBe('general');
|
||
expect(before[0].triplet_count).toBe(0); // 誠實:桶名對得上、數字沒謊報,但也沒把 3 筆算近來(見上註)
|
||
|
||
// ── 補標(源頭已對:先 ensure slot,才寫值;一次對全部缺值的 record)──
|
||
await ensureTripletLibrarySlot(db, 'triplet');
|
||
const allTriplets = await searchByTemplate(db, 'triplet', 'leo');
|
||
const missing = allTriplets.filter((t) => !t.values.library && t.values.source_uri);
|
||
expect(missing.length).toBe(3); // 三筆都缺
|
||
for (const t of missing) {
|
||
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri) });
|
||
}
|
||
|
||
// 補標後:對每個實際出現的 library 值重算一次地圖(read-path 的 ensureFreshLibraryMaps
|
||
// 只認「已知庫名」——entries.metadata.library 或 portal_library;剛補標的三元組 library 值
|
||
// 尚未被任何一處登記為「已知庫名」,直接 recompute 該庫最直接、也是 caller 實際會做的事)。
|
||
const libs = [...new Set(missing.map((t) => deriveLibrary(t.values.source_uri!)))];
|
||
for (const lib of libs) {
|
||
await recomputeLibraryMap(db, { library: lib, owner_id: 'leo' });
|
||
}
|
||
|
||
const after = await listLibraryMaps(db, 'leo');
|
||
const byLib = new Map(after.map((m) => [m.library, m.triplet_count]));
|
||
expect(byLib.get('gitea:Leo/kb')).toBe(2); // r1, r2 同庫 —— 這是 leo 真正要看到的東西
|
||
expect(byLib.get('github:uncle6-me/notes')).toBe(1); // r3 另一庫
|
||
// 'general' 桶(補標前遺留的空殼,見上一段註)仍在,但 triplet_count 仍是 0——
|
||
// 三筆全部被正確歸進真正的庫名,沒有一筆被算進 general(no double counting)。
|
||
expect(byLib.get('general')).toBe(0);
|
||
expect(after.length).toBe(3);
|
||
|
||
// 交叉核對:三筆的 library 值確實都落地了(不是只有地圖聚合對,底層資料也對)。
|
||
const r1Rec = await getRecord(db, r1.record_id);
|
||
const r2Rec = await getRecord(db, r2.record_id);
|
||
const r3Rec = await getRecord(db, r3.record_id);
|
||
expect(r1Rec!.values.library).toBe('gitea:Leo/kb');
|
||
expect(r2Rec!.values.library).toBe('gitea:Leo/kb');
|
||
expect(r3Rec!.values.library).toBe('github:uncle6-me/notes');
|
||
});
|
||
});
|
||
|
||
describe('不會重複做 — 同一批補標跑兩次,第二次不改動任何東西', () => {
|
||
it('第二輪掃描:已有 library 值的 record 一筆都不會被 touch', async () => {
|
||
const db = makeSqliteD1();
|
||
await seedTripletTemplate(db);
|
||
await ensureTripletLibrarySlot(db, 'triplet');
|
||
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'leo' });
|
||
// r2 模擬「還沒補標」的舊資料:故意繞過 values 直接不給 library(createRecord 這次雖然
|
||
// template 已有 slot,但 caller 沒給值 → 該 slot 完全不會被建立 entry_value,等同「缺」)。
|
||
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md' }, owner_id: 'leo' });
|
||
await updateRecord(db, r1.record_id, { library: deriveLibrary('gitea:Leo/kb@a.md') }); // r1 先補過
|
||
|
||
async function backfillPass(): Promise<number> {
|
||
const all = await searchByTemplate(db, 'triplet', 'leo');
|
||
const missing = all.filter((t) => !t.values.library && t.values.source_uri);
|
||
for (const t of missing) {
|
||
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri!) });
|
||
}
|
||
return missing.length;
|
||
}
|
||
|
||
const firstPass = await backfillPass();
|
||
expect(firstPass).toBe(1); // 只有 r2 被補(r1 已有值,跳過)
|
||
|
||
const secondPass = await backfillPass();
|
||
expect(secondPass).toBe(0); // 第二輪:兩筆都已有 library,一筆都不 touch
|
||
|
||
// 資料仍然正確、沒被第二輪弄壞。
|
||
const r1After = await getRecord(db, r1.record_id);
|
||
const r2After = await getRecord(db, r2.record_id);
|
||
expect(r1After!.values.library).toBe('gitea:Leo/kb');
|
||
expect(r2After!.values.library).toBe('gitea:Leo/kb');
|
||
});
|
||
});
|