Files
Arcrun/kbdb/tests/library-map.test.ts
T
uncle6me-web 5919c6b90f fix(kbdb): 藏書地圖讀端每次都觸發重算寫 D1(Arcrun#87 止血)
liveTripletCountsByLibrary 沒有過濾 status,recomputeLibraryMap 只算
COALESCE(status,'active')='active'——兩邊判準不一致,只要庫裡有一筆
superseded triplet 就永遠判定 stale,導致每次讀地圖(GET /map、GET /map/:library、
kbdb_get_map MCP 工具)都觸發重算並新建一筆 library_map record,無止盡寫 D1,
且加劇既有的非原子 supersede 競態(kb 44 筆全 superseded、notes 兩筆同時 active
即 arcrun-rag#50 的共同根因之一)。

修法:liveTripletCountsByLibrary 的 SQL 改成 pivot 出 status 再套用與
recomputeLibraryMap 逐字一致的過濾,兩邊判準對齊。

新增迴歸測試釘住此 bug(反向驗證:跑在修前的 SQL 上會失敗,非空氣測試);
獨立用 leo21c MCP 連線連讀兩次 kbdb_get_map() 重現修前症狀
(general 庫 updated_at 從 1786457080 前進到 1786457114,中間無寫入)。

kbdb/tests/library-map.test.ts 19/19 全綠。既有殘骸(100 筆 library_map record)
未清——目前沒有可用的 DELETE 通道,待部署後另行處理。

kbdb-sql-ok:liveTripletCountsByLibrary 的 .prepare 呼叫是牆內本體
(kbdb/src/actions/),本次 checkout 開在巢狀 worktree
matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的
matrix/arcrun 主 checkout),kbdb-api-wall-guard.sh 的字面路徑比對
*matrix/arcrun/kbdb/src/* 吃不到中間多出的 worktree 目錄層,非真的繞牆。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:16:30 +08:00

385 lines
21 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.
// library-map(藏書地圖)M1+M2 — SDD system-dev/docs/3-specs/library-map(源頭 Arcrun#39)。
// 測試策略:聚合 SQLdegree 排序/predicate 統計/跨庫 joinsupersede 查找)用「真 SQLite」驗——
// node:sqliteNode ≥22.5 內建,零新依賴)跑 migrations/0001+0003 原檔,比 capture-DB 只驗 SQL
// 形狀更硬;route 行為(參數解析/400/404/回應形狀)走 Hono app.request,與既有測試同款。
// 註:D1 語意與 SQLite 幾乎同源,僅 session/consistency 層不同——本測試覆蓋的純 SQL 聚合在兩邊等價。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { Hono } from 'hono';
import { mapRoutes } from '../src/routes/map';
import {
recomputeLibraryMap,
listLibraryMaps,
getLibraryMapDetail,
ensureTripletLibrarySlot,
ensureFreshLibraryMaps,
LIBRARY_MAP_SLOTS,
} from '../src/actions/library-map';
import { createTemplate, createRecord, getRecord, getTemplate, searchByTemplate } from '../src/actions/record-crud';
import { createEntry } from '../src/actions/entry-crud';
import type { Bindings } from '../src/types';
// ── node:sqlite → D1 介面最小 adapterprepare/bind/all/first/run,本 codebase 只用這些)──
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8'));
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[] }; },
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; },
async run() { raw.prepare(sql).run(...params); return { success: true }; },
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
// prod 實際 triplet template 的 slots2026-07-19 kbdb_list_templates 核實)——注意:沒有 library。
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' });
}
async function seedTriplet(
db: D1Database,
v: { s: string; p: string; o: string; library?: string; source_uri?: string; status?: string },
owner = 'leo',
): Promise<string> {
const values: Record<string, string> = { subject: v.s, predicate: v.p, object: v.o };
if (v.library) values.library = v.library;
if (v.source_uri) values.source_uri = v.source_uri;
if (v.status) values.status = v.status;
const rec = await createRecord(db, { template: 'triplet', values, owner_id: owner });
return rec.record_id;
}
function makeApp(db: D1Database) {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/map', mapRoutes);
const env = { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings;
return { app, env };
}
describe('M1 — library_map templatetriplet library slot(真 SQLite', () => {
it('migration 0003 seedlibrary_map template 落在 templates 表、slots 齊全(D6 零建表)', async () => {
const db = makeSqliteD1();
const tpl = await getTemplate(db, 'library_map');
expect(tpl).not.toBeNull();
expect(JSON.parse(tpl!.slots_json)).toEqual(LIBRARY_MAP_SLOTS);
});
it('ensureTripletLibrarySlotprod 形狀(無 library slot)→ 補上;再跑冪等 false', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
expect(await ensureTripletLibrarySlot(db, 'triplet')).toBe(true);
const tpl = await getTemplate(db, 'triplet');
expect(JSON.parse(tpl!.slots_json)).toContain('library');
expect(await ensureTripletLibrarySlot(db, 'triplet')).toBe(false); // 只增不減、冪等
});
it('recompute 對缺 triplet template 的 DB → 誠實丟錯(不假裝算出空地圖)', async () => {
const db = makeSqliteD1();
await expect(recomputeLibraryMap(db, { library: 'kb' })).rejects.toThrow('triplet template not found');
});
});
describe('M2 — recompute 聚合(真 SQLite 實跑 SQL', () => {
async function seedKbLibrary(db: D1Database) {
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// kb 庫:A 出現 3 次(degree 3)、B 2 次、C 1 次;predicate 連結至×2、屬於×1
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
await seedTriplet(db, { s: 'B', p: '屬於', o: 'A', library: 'kb' });
}
it('top_entities degree 排序+relation_profiletriplet_countcontent 可嵌人話', async () => {
const db = makeSqliteD1();
await seedKbLibrary(db);
const r = await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫卡片', commit_hash: 'abc123' });
expect(r.map.triplet_count).toBe(3);
expect(r.map.top_entities).toEqual([
{ name: 'A', degree: 3 },
{ name: 'B', degree: 2 },
{ name: 'C', degree: 1 },
]);
expect(r.map.relation_profile).toEqual([
{ predicate: '連結至', count: 2 },
{ predicate: '屬於', count: 1 },
]);
// design §5content = `{library}{narrative}。核心:{top 前幾個}`M6 semantic 路由直接嵌)
expect(r.map.content).toBe('kb:知識庫卡片。核心:A、B、C');
expect(r.map.commit_hash).toBe('abc123');
expect(r.superseded).toEqual([]);
expect(r.triplet_library_slot_added).toBe(false); // seed 已補過 slot → recompute 冪等回 false
});
it('superseded triplet 不入地圖(COALESCE status active-only', async () => {
const db = makeSqliteD1();
await seedKbLibrary(db);
await seedTriplet(db, { s: 'X', p: '連結至', o: 'Y', library: 'kb', status: 'superseded' });
const r = await recomputeLibraryMap(db, { library: 'kb' });
expect(r.map.triplet_count).toBe(3);
expect(r.map.top_entities.map((t) => t.name)).not.toContain('X');
});
it('source_prefix fallback:舊 triplet(無 library 值)靠 source_uri 前綴歸庫;不帶 fallback 則不計', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
// 模擬 prod 現況:triplet 只有 source_urigitea:Leo/kb@…),沒有 library slot 值
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', source_uri: 'gitea:Leo/kb@system-dev/wiki/cards/kb/00-INDEX.md#seg02' });
await seedTriplet(db, { s: 'B', p: '連結至', o: 'C', source_uri: 'gitea:Leo/kb@system-dev/wiki/cards/kb/x.md' });
await seedTriplet(db, { s: 'D', p: '連結至', o: 'E', source_uri: 'gitea:Leo/notes@cards/y.md' }); // 他庫
const strict = await recomputeLibraryMap(db, { library: 'kb' });
expect(strict.map.triplet_count).toBe(0); // library slot 全空 → 嚴格模式誠實回 0
const fb = await recomputeLibraryMap(db, { library: 'kb', source_prefix: 'gitea:Leo/kb@' });
expect(fb.map.triplet_count).toBe(2);
expect(fb.map.top_entities.map((t) => t.name)).toEqual(['B', 'A', 'C']);
});
it('bridges:本庫 entity 出現在其他庫(library slot 標記側)→ 跨庫 join 列出', async () => {
const db = makeSqliteD1();
await seedKbLibrary(db);
await seedTriplet(db, { s: 'A', p: '參與', o: 'Z', library: 'notes' }); // A 橫跨 kb/notes
const r = await recomputeLibraryMap(db, { library: 'kb' });
expect(r.map.bridges).toEqual([{ entity: 'A', libraries: ['notes'] }]);
});
it('owner 隔離:帶 owner_id 只聚合該 owner 的 triplet(照 base 既有 owner 慣例)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }, 'tenant1');
await seedTriplet(db, { s: 'C', p: '連結至', o: 'D', library: 'kb' }, 'tenant2');
const r = await recomputeLibraryMap(db, { library: 'kb', owner_id: 'tenant1' });
expect(r.map.triplet_count).toBe(1);
expect(r.map.top_entities.map((t) => t.name).sort()).toEqual(['A', 'B']);
});
it('supersede 順序安全:重算兩次 → 舊 map 標 superseded、讀端只見最新 active', async () => {
const db = makeSqliteD1();
await seedKbLibrary(db);
const first = await recomputeLibraryMap(db, { library: 'kb', narrative: '第一版' });
await seedTriplet(db, { s: 'A', p: '連結至', o: 'D', library: 'kb' });
const second = await recomputeLibraryMap(db, { library: 'kb', narrative: '第二版' });
expect(second.superseded).toEqual([first.map.record_id]); // 舊 active 被點名
const oldRec = await getRecord(db, first.map.record_id);
expect(oldRec!.values.status).toBe('superseded'); // 沿用既有 status slot 語意(R6
const detail = await getLibraryMapDetail(db, 'kb');
expect(detail!.record_id).toBe(second.map.record_id);
expect(detail!.narrative).toBe('第二版');
expect(detail!.triplet_count).toBe(4);
// 全館視圖同樣只剩一行 kb(superseded 不重複出現)
const all = await listLibraryMaps(db);
expect(all.filter((r) => r.library === 'kb')).toHaveLength(1);
});
});
describe('M2 — route 行為(GET /map、GET /map/:library、POST /map/recompute', () => {
it('POST /map/recompute 缺 library → 400query 傳 librarybody 傳 narrative 可重算', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const { app, env } = makeApp(db);
const bad = await app.request('/map/recompute', { method: 'POST' }, env);
expect(bad.status).toBe(400);
const ok = await app.request('/map/recompute?library=kb', {
method: 'POST',
body: JSON.stringify({ narrative: '知識庫' }),
headers: { 'content-type': 'application/json' },
}, env);
expect(ok.status).toBe(200);
const body = (await ok.json()) as { success: boolean; map: { library: string; content: string } };
expect(body.success).toBe(true);
expect(body.map.library).toBe('kb');
expect(body.map.content).toContain('kb:知識庫');
});
it('GET /map:從未 recomputetemplate 不存在)→ 誠實空清單;有資料 → 每庫一行、top 3 名字', async () => {
const empty = makeSqliteD1();
// 空 DB 連 library_map template 都拿掉,模擬「migration 未跑、也從未 recompute」的自架環境
await (empty as unknown as { prepare(sql: string): { run(): Promise<unknown> } })
.prepare("DELETE FROM templates WHERE name = 'library_map'").run();
const e = makeApp(empty);
const r0 = await e.app.request('/map', {}, e.env);
expect(r0.status).toBe(200);
expect(await r0.json()).toEqual({ success: true, libraries: [], count: 0 });
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// kb 庫塞 4 個 entities(驗 top_entities 全館視圖只留 3
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
await seedTriplet(db, { s: 'A', p: '連結至', o: 'D', library: 'kb' });
await seedTriplet(db, { s: 'X', p: '參與', o: 'Y', library: 'notes' });
await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫' });
await recomputeLibraryMap(db, { library: 'notes', narrative: '隨手筆記' });
const { app, env } = makeApp(db);
const res = await app.request('/map', {}, env);
const body = (await res.json()) as { libraries: { library: string; narrative: string; top_entities: string[]; triplet_count: number }[]; count: number };
expect(body.count).toBe(2);
const kb = body.libraries.find((l) => l.library === 'kb')!;
expect(kb.narrative).toBe('知識庫');
expect(kb.triplet_count).toBe(3);
expect(kb.top_entities).toEqual(['A', 'B', 'C']); // 每庫一行只留 top 3R3 數百 token 內)
});
it('GET /map/:library:完整 slotscontent;未知庫 404', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
await recomputeLibraryMap(db, { library: 'kb', narrative: '知識庫', commit_hash: 'deadbeef' });
const { app, env } = makeApp(db);
const res = await app.request('/map/kb', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { map: Record<string, unknown> };
expect(body.map.library).toBe('kb');
expect(body.map.commit_hash).toBe('deadbeef');
expect(body.map.status).toBe('active');
expect(Array.isArray(body.map.relation_profile)).toBe(true);
expect(Array.isArray(body.map.bridges)).toBe(true);
expect(body.map.content).toBe('kb:知識庫。核心:A、B');
const miss = await app.request('/map/nope', {}, env);
expect(miss.status).toBe(404);
});
});
// 2026-08-08M3 收尾——真因是「等外部呼叫 /map/recompute」這條線三週沒人接(總管實測 grep
// 全 repo 查無呼叫點),沒手動 backfill 過的租戶恆空。修法:讀端自己核對即時三元組數,落差
// 就地補算,不再依賴任何外部呼叫者。以下驗證這條「即時新鮮度」機制本身。
describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動核對重算,不靠外部呼叫 recompute', () => {
it('從未手動呼過 recomputeGET /map 第一次讀就自動補齊(全租戶自動 backfill', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
await seedTriplet(db, { s: 'X', p: '參與', o: 'Y', library: 'notes' });
// 注意:這裡沒有呼叫 recomputeLibraryMap,直接打 GET /map。
const { app, env } = makeApp(db);
const res = await app.request('/map', {}, env);
const body = (await res.json()) as { libraries: { library: string; triplet_count: number }[]; count: number };
expect(body.count).toBe(2);
const kb = body.libraries.find((l) => l.library === 'kb')!;
expect(kb.triplet_count).toBe(2);
const notes = body.libraries.find((l) => l.library === 'notes')!;
expect(notes.triplet_count).toBe(1);
});
it('跟得上資料:先讀一次,再塞新三元組,下一次讀(不手動 recompute)數字要更新', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
const { app, env } = makeApp(db);
const first = await app.request('/map', {}, env);
const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
// 模擬 ingest 進了一筆新資料——不呼叫任何 recompute。
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
const second = await app.request('/map', {}, env);
const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
});
it('Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次觸發重算(不再無止盡寫入)', async () => {
// 重現票上的根因:liveTripletCountsByLibrary 原本不濾 statusrecomputeLibraryMap 只算
// active——只要庫裡混了 superseded triplet,兩邊算出來的數字永遠對不上,
// ensureFreshLibraryMaps 就永遠判定 stale,每次讀地圖都重算、每次都新建一筆 record。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); // active
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb', status: 'superseded' }); // 已淘汰
const { app, env } = makeApp(db);
// 第一次讀:資料是新的(從沒 recompute 過),觸發一次重算是正常的。
const first = await app.request('/map', {}, env);
const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1); // 只算 active 那筆
const countAfterFirst = (await searchByTemplate(db, 'library_map')).length;
// 第二次讀:中間沒有任何寫入動作。修好之前,這裡會再次判定 stale 並多新建一筆 record。
const second = await app.request('/map', {}, env);
const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
const countAfterSecond = (await searchByTemplate(db, 'library_map')).length;
expect(countAfterSecond).toBe(countAfterFirst); // 沒有新增任何 library_map record
// 第三次也一樣,多讀幾次確認不是巧合。
await app.request('/map', {}, env);
const countAfterThird = (await searchByTemplate(db, 'library_map')).length;
expect(countAfterThird).toBe(countAfterFirst);
});
it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
await recomputeLibraryMap(db, { library: 'kb', narrative: '人工填過的摘要' });
// 塞新三元組觸發下一次讀時的自動重算(不帶 narrative)。
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb' });
await ensureFreshLibraryMaps(db);
const detail = await getLibraryMapDetail(db, 'kb');
expect(detail!.triplet_count).toBe(2); // 確認真的有重算(不是沒動過)
expect(detail!.narrative).toBe('人工填過的摘要'); // 但 narrative 沒被洗掉
});
it('GET /map/:library 誠實分辨「查無此庫」(404) vs「已知但目前是空庫」(200triplet_count:0)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// 'hr' 庫:entries 蓋過章(t52 慣例)但目前沒有任何三元組——已知但空。
await createEntry(db, {
content: '人資資料',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
const { app, env } = makeApp(db);
const known = await app.request('/map/hr?owner_id=leo', {}, env);
expect(known.status).toBe(200); // 已知庫,即使是空的也回 200,不是 404
const knownBody = (await known.json()) as { map: { triplet_count: number } };
expect(knownBody.map.triplet_count).toBe(0);
const unknown = await app.request('/map/totally-made-up-name?owner_id=leo', {}, env);
expect(unknown.status).toBe(404); // 真的從沒出現過的名字才 404
});
it('owner 隔離:即時新鮮度層不會把別的 owner 的三元組算進來', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }, 'tenant1');
await seedTriplet(db, { s: 'C', p: '連結至', o: 'D', library: 'kb' }, 'tenant2');
const { app, env } = makeApp(db);
const res = await app.request('/map?owner_id=tenant1', {}, env);
const body = (await res.json()) as { libraries: { library: string; triplet_count: number }[] };
expect(body.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
});
it('沒有 triplet template(這顆 KBDB 從沒建過任何三元組)→ 不報錯,誠實回空清單', async () => {
// 新鮮 DB:只跑過 migrationslibrary_map template 有 seed,但沒人叫過 seedTripletTemplate)。
const fresh = makeSqliteD1();
await expect(ensureFreshLibraryMaps(fresh)).resolves.toBeUndefined();
const { app, env } = makeApp(fresh);
const res = await app.request('/map', {}, env);
expect(await res.json()).toEqual({ success: true, libraries: [], count: 0 });
});
});