Files
Arcrun/kbdb/tests/library-map.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

443 lines
24 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'));
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[] }; },
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('entry_count 讓「有原始內容但三元組從沒萃取過」與「真的什麼都沒有」分得清(Arcrun#87 三次收尾)', async () => {
// 情境沿用上一案的 'hr' 庫(entries 蓋過章、triplet_count:0),這正是 leo21c 實測到的真實
// 現況(kb 以外 7 庫皆此況):地圖過去只回 triplet_count,AI 讀到 0 就誤判「這庫沒有知識」,
// 但實際上 kbdb_search 找得到內容——因為 entries 一直都在,只是沒被萃取成三元組。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await createEntry(db, {
content: '人資資料 A',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
await createEntry(db, {
content: '人資資料 B',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
const { app, env } = makeApp(db);
// 單庫詳圖:triplet_count 仍是 0(沒騙這件事),但 entry_count 誠實回 2。
const detailRes = await app.request('/map/hr?owner_id=leo', {}, env);
const detailBody = (await detailRes.json()) as { map: { triplet_count: number; entry_count: number } };
expect(detailBody.map.triplet_count).toBe(0);
expect(detailBody.map.entry_count).toBe(2);
// 全館視圖:同一個庫、同一組數字,兩個 consumerGET /map、GET /map/:library)不能對不上。
const listRes = await app.request('/map?owner_id=leo', {}, env);
const listBody = (await listRes.json()) as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
const hr = listBody.libraries.find((l) => l.library === 'hr');
expect(hr).toBeDefined();
expect(hr!.triplet_count).toBe(0);
expect(hr!.entry_count).toBe(2);
});
it('entry_count 的計數口徑排除 value 型 entries 與地圖自己的歷史摘要 block(不自我膨脹)', async () => {
// record-crud 建 triplet record 時,每個 slot 值都會落一筆 entry_type='value' 的 entry
// (儲存實作細節,不是「一筆知識」);地圖 recompute 也會建 entry_type='block' 且
// metadata.kind='library_map' 的摘要 entry——兩者都不該被算進 entry_count,否則地圖會把
// 自己的內部管線雜訊當成「使用者知識」回報,數字沒有意義。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// 建一條 kb 三元組 → 連帶產生數筆 entry_type='value' 的 entries(不該被算進 entry_count)。
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
// 觸發一次 recompute → 產生一筆 entry_type='block'/metadata.kind='library_map' 的摘要 entry
// (同樣不該被算進 entry_count)。
await recomputeLibraryMap(db, { library: 'kb' });
const detail = await getLibraryMapDetail(db, 'kb');
// 這個情境下 kb 沒有任何「真的 ingest 進來的原始內容」entry(只有 value 碎片與地圖自己的摘要),
// entry_count 應誠實回 0——不能因為底層 entries 表其實有好幾筆就報出一個誤導的非零數字。
expect(detail!.entry_count).toBe(0);
expect(detail!.triplet_count).toBe(1); // 對照:triplet_count 不受這個排除規則影響,維持原樣。
});
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 });
});
});