diff --git a/cypher-executor/src/routes/kbdb-proxy.ts b/cypher-executor/src/routes/kbdb-proxy.ts index 8c0f441..62a3ef3 100644 --- a/cypher-executor/src/routes/kbdb-proxy.ts +++ b/cypher-executor/src/routes/kbdb-proxy.ts @@ -119,6 +119,27 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => { return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); +// PATCH /kbdb/records/:recordId — 翻某筆 record 的 slot 值({ values:{slot:content} })。 +// 補上基本盤既有能力(kbdb/src/routes/records.ts 的 PATCH /records/:recordId,mira-dissolve T2.1) +// 缺的對外通道——2026-08-11 leo 三元組 library 補標核實:base 早有這個端點,但這條 proxy +// 之前只轉發 GET/POST,插件/工作流打不到,補標三元組只能繞去改表(違 D38)。單純轉發,無業務邏輯。 +// by-id 沿用既有慣例(require-key,不額外做 owner 比對——與本檔 GET .../:recordId、 +// PATCH /kbdb/entries/:id 同款)。 +kbdbProxyRouter.patch('/kbdb/records/:recordId', async (c) => { + if (!tenant(c)) return c.json(NEED_KEY, 401); + const body = await c.req.json().catch(() => null); + if (!body || typeof body.values !== 'object' || body.values === null) { + return c.json({ error: 'values 必填({slot名: 內容})' }, 400); + } + const { base, headers } = kbdbBase(c.env); + const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param('recordId'))}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ values: body.values }), + }); + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); +}); + // ── search(限本租戶範圍內)──────────────────────────────────────────────────── // GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。 diff --git a/cypher-executor/tests/kbdb-records-patch-proxy.test.ts b/cypher-executor/tests/kbdb-records-patch-proxy.test.ts new file mode 100644 index 0000000..36be534 --- /dev/null +++ b/cypher-executor/tests/kbdb-records-patch-proxy.test.ts @@ -0,0 +1,89 @@ +/** + * PATCH /kbdb/records/:recordId proxy 測試(2026-08-11,三元組 library 補標需求核實) + * + * 背景:基本盤 kbdb/src/routes/records.ts 早有 PATCH /records/:recordId(mira-dissolve T2.1, + * updateRecord 已支援「補一個 record 原本沒有的 slot 值」的 idempotent grow)。但這條 cypher + * proxy(kbdb-proxy.ts)之前只轉發 GET/POST /kbdb/records,沒開 PATCH——外部(工作流/CLI/ + * 任何走 X-Arcrun-API-Key 的呼叫者)打不到,等於基本盤能力在,通道沒開。 + * + * 驗證 IO 接線(聚合真身在 KBDB 基本盤,這裡只測轉發,比照 kbdb-map-proxy.test.ts 慣例): + * 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB + * 2. body 沒有 values → 400,不轉發 + * 3. 轉發:PATCH /kbdb/records/:id → base PATCH /records/:id,body 只帶 { values } + * 4. base 404(record 不存在)→ 原樣透傳,不假裝成功 + * + * KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+ + * disableNetConnect——測試絕不外連。 + */ +import { SELF, fetchMock } from 'cloudflare:test'; +import { beforeAll, afterEach, describe, it, expect } from 'vitest'; + +const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' }; + +beforeAll(() => { + fetchMock.activate(); + fetchMock.disableNetConnect(); +}); +afterEach(() => fetchMock.assertNoPendingInterceptors()); + +describe('PATCH /kbdb/records/:recordId — 租戶閘', () => { + it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => { + const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values: { library: 'kb' } }), + }); + expect(res.status).toBe(401); + }); +}); + +describe('PATCH /kbdb/records/:recordId — 參數驗證', () => { + it('body 沒有 values → 400,不轉發', async () => { + const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', { + method: 'PATCH', + headers: KEY, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); +}); + +describe('PATCH /kbdb/records/:recordId — 轉發', () => { + it('轉發 base PATCH /records/:id,body 只帶 values(不夾帶其他欄位)', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ + path: '/records/rec_1', + method: 'PATCH', + body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }), + }) + .reply(200, { + success: true, + record: { record_id: 'rec_1', template_id: 'tpl-triplet', values: { library: 'gitea:Leo/kb' } }, + }); + const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', { + method: 'PATCH', + headers: KEY, + body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }), + }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; record: { values: Record } }; + expect(data.success).toBe(true); + expect(data.record.values.library).toBe('gitea:Leo/kb'); + }); + + it('base 404(record 不存在)→ 原樣透傳,不假裝成功', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ path: '/records/nope', method: 'PATCH' }) + .reply(404, { success: false, error: 'not found' }); + const res = await SELF.fetch('http://localhost/kbdb/records/nope', { + method: 'PATCH', + headers: KEY, + body: JSON.stringify({ values: { library: 'kb' } }), + }); + expect(res.status).toBe(404); + const data = (await res.json()) as { success: boolean }; + expect(data.success).toBe(false); + }); +}); diff --git a/kbdb/tests/triplet-library-backfill.test.ts b/kbdb/tests/triplet-library-backfill.test.ts new file mode 100644 index 0000000..85956e8 --- /dev/null +++ b/kbdb/tests/triplet-library-backfill.test.ts @@ -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() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 記憶體測試替身 + async first() { 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 { + 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 { + 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'); + }); +});