merge: 併入 main 最新(b6ef0f0 三元組 library 補標 proxy+測試)保持分支不落後
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// 三元組 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: 同上
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user