From 179dd60571775efb1c7dd5c5bfd17b67d7909a38 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Thu, 13 Aug 2026 11:12:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(kbdb):=20=E4=B8=89=E5=85=83=E7=B5=84?= =?UTF-8?q?=E7=89=88=E6=A8=99=E5=BA=AB=E6=89=B9=E6=AC=A1=E8=A3=9C=E5=AD=98?= =?UTF-8?q?=E9=87=8F=EF=BC=8B=E5=B0=8D=E5=A4=96=E9=80=9A=E9=81=93=EF=BC=88?= =?UTF-8?q?Arcrun#87=20=E4=BA=8C=E6=AC=A1=E6=94=B6=E5=B0=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 藏書地圖(library-map.ts)讀的是三元組 record 自己的 'library' slot(entry_values), 不是 entries.metadata_json.library——Arcrun#85 建的 POST /entries/backfill-library 補的是後者,補了地圖依然是 0。既有能補三元組 library 值的通道只有單筆 PATCH /kbdb/records/:id(b6ef0f0,已在 main),母體上千筆時逐筆呼叫不現實,也沒有 節流/冪等保護。 新增: - kbdb/src/actions/library-backfill.ts:backfillTripletLibraryTags + tripletLibraryBackfillStatus,安全原則與既有 entries 版逐條對齊——呼叫端決定 library 值+source_prefix 篩選(base 不猜語意)、冪等(NOT EXISTS 找缺 library 的 那半)、與 entries 版/embed reconcile 共用同一顆 D69 每日 D1 寫入額度、owner_id 必填、 寫入沿用既有 updateRecord(不手刻第二套 entry_values UPSERT SQL)。 - kbdb/src/routes/records.ts:POST /records/backfill-library + GET .../status。 - cypher-executor/src/routes/kbdb-proxy.ts:對外通道,owner_id 強制用租戶身份(同本檔 既有 POST /kbdb/records 慣例,不信任 caller 自帶 owner_id)。 測試:kbdb 新增 5 案(含跨租戶隔離/冪等/D69 共用額度截斷/status 統計),全套 218/218 綠燈;cypher-executor 新增 5 案(租戶閘/參數驗證/轉發/owner_id 覆蓋), 兩份既有失敗(auth.test.ts ExecutionContext 型別缺陷、portal-data/portal-admin/ console-library-map-page/executor.test.ts 訊息措辭)複驗與本次改動無關(stash 前後 一致,pre-existing)。 紅線:未併 main、未部署、未對 leo21c 執行任何實際 backfill(缺安全的寫入通道—— MCP 工具集無 update/patch record 能力,CLI 全域設定指著 leo21c 不可裸跑)。 批次執行是下一步,需總管/leo 決定執行方式與時機。 Co-Authored-By: Claude Opus 5 --- cypher-executor/src/routes/kbdb-proxy.ts | 39 +++++ ...bdb-records-backfill-library-proxy.test.ts | 100 +++++++++++ kbdb/src/actions/library-backfill.ts | 129 ++++++++++++++ kbdb/src/routes/records.ts | 49 ++++++ .../triplet-library-batch-backfill.test.ts | 164 ++++++++++++++++++ 5 files changed, 481 insertions(+) create mode 100644 cypher-executor/tests/kbdb-records-backfill-library-proxy.test.ts create mode 100644 kbdb/tests/triplet-library-batch-backfill.test.ts diff --git a/cypher-executor/src/routes/kbdb-proxy.ts b/cypher-executor/src/routes/kbdb-proxy.ts index 0074369..af4522b 100644 --- a/cypher-executor/src/routes/kbdb-proxy.ts +++ b/cypher-executor/src/routes/kbdb-proxy.ts @@ -111,6 +111,45 @@ kbdbProxyRouter.get('/kbdb/records/by-template/:template', async (c) => { return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); +// POST /kbdb/records/backfill-library — 三元組版標庫補存量對外通道(Arcrun#87 二次收尾,2026-08-13)。 +// 基本盤(kbdb/src/routes/records.ts)的 POST /records/backfill-library 缺對外通道——同 +// PATCH /kbdb/records/:recordId 那條的破口(能力在 base,插件/工作流打不到)。純轉發, +// owner_id 強制用租戶身份(同本檔 POST /kbdb/records 的既有慣例,不信任 caller 自帶 owner_id)。 +// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。 +kbdbProxyRouter.post('/kbdb/records/backfill-library', async (c) => { + const owner = tenant(c); + if (!owner) return c.json(NEED_KEY, 401); + const body = await c.req.json().catch(() => null); + if (!body || !body.library) return c.json({ error: 'library 必填' }, 400); + const { base, headers } = kbdbBase(c.env); + const res = await fetch(`${base}/records/backfill-library`, { + method: 'POST', + headers, + body: JSON.stringify({ + library: body.library, + owner_id: owner, + triplet_template: body.triplet_template, + source_prefix: body.source_prefix, + limit: body.limit, + }), + }); + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); +}); + +// GET /kbdb/records/backfill-library/status — 待補標統計(三元組版),owner_id 同樣強制用租戶身份。 +kbdbProxyRouter.get('/kbdb/records/backfill-library/status', async (c) => { + const owner = tenant(c); + if (!owner) return c.json(NEED_KEY, 401); + const { base, headers } = kbdbBase(c.env); + const params = new URLSearchParams({ owner_id: owner }); + for (const k of ['triplet_template', 'source_prefix']) { + const v = c.req.query(k); + if (v) params.set(k, v); + } + const res = await fetch(`${base}/records/backfill-library/status?${params.toString()}`, { headers }); + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); +}); + // GET /kbdb/records/:recordId — 取單筆 record。 kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => { if (!tenant(c)) return c.json(NEED_KEY, 401); diff --git a/cypher-executor/tests/kbdb-records-backfill-library-proxy.test.ts b/cypher-executor/tests/kbdb-records-backfill-library-proxy.test.ts new file mode 100644 index 0000000..e175dd0 --- /dev/null +++ b/cypher-executor/tests/kbdb-records-backfill-library-proxy.test.ts @@ -0,0 +1,100 @@ +/** + * POST /kbdb/records/backfill-library + GET .../status proxy 測試(Arcrun#87 二次收尾,2026-08-13) + * + * 背景:基本盤 kbdb/src/routes/records.ts 新增了三元組版的批次標庫補存量端點 + * (backfillTripletLibraryTags——藏書地圖讀的是三元組 record 自己的 'library' slot,跟 + * entries 版 backfill-library 補的 metadata_json.library 是不同存放處)。這條 cypher proxy + * 之前完全沒轉發這兩支——同 kbdb-records-patch-proxy.test.ts 那次的破口(能力在 base, + * 插件/工作流打不到)。純轉發,owner_id 強制用租戶身份(不信任 caller 自帶 owner_id, + * 同本檔既有 POST /kbdb/records 的慣例)。 + * + * 驗證 IO 接線(聚合真身在 KBDB 基本盤,這裡只測轉發,比照 kbdb-records-patch-proxy.test.ts 慣例): + * 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB + * 2. body 沒有 library → 400,不轉發 + * 3. 轉發:owner_id 一律用租戶身份覆蓋(即使 caller 自帶了別的 owner_id 也被忽略) + * 4. GET status:owner_id 同樣強制用租戶身份,query 參數透傳 + * + * KBDB 打 fetchMock 假 host+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('POST /kbdb/records/backfill-library — 租戶閘', () => { + it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => { + const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ library: 'arcrun' }), + }); + expect(res.status).toBe(401); + }); +}); + +describe('POST /kbdb/records/backfill-library — 參數驗證', () => { + it('body 沒有 library → 400,不轉發', async () => { + const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', { + method: 'POST', + headers: KEY, + body: JSON.stringify({ source_prefix: 'gitea:Leo/Arcrun@' }), + }); + expect(res.status).toBe(400); + }); +}); + +describe('POST /kbdb/records/backfill-library — 轉發', () => { + it('owner_id 一律用租戶身份覆蓋,即使 caller 自帶了別的 owner_id', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ + path: '/records/backfill-library', + method: 'POST', + body: JSON.stringify({ + library: 'arcrun', + owner_id: 'leo', // 來自 X-Arcrun-API-Key,不是 body 裡的 'someone-else' + triplet_template: undefined, + source_prefix: 'gitea:Leo/Arcrun@', + limit: 200, + }), + }) + .reply(200, { success: true, library: 'arcrun', scanned: 208, tagged: 200, remaining: 8, quota_limit: 2000, quota_used_today: 200, quota_exceeded: true }); + const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', { + method: 'POST', + headers: KEY, + body: JSON.stringify({ library: 'arcrun', owner_id: 'someone-else', source_prefix: 'gitea:Leo/Arcrun@', limit: 200 }), + }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; tagged: number; remaining: number }; + expect(data.success).toBe(true); + expect(data.tagged).toBe(200); + expect(data.remaining).toBe(8); + }); +}); + +describe('GET /kbdb/records/backfill-library/status — 租戶閘 + 轉發', () => { + it('無 X-Arcrun-API-Key → 401', async () => { + const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@'); + expect(res.status).toBe(401); + }); + + it('owner_id 強制用租戶身份,其餘 query 參數透傳', async () => { + fetchMock + .get('https://kbdb.test') + .intercept({ path: '/records/backfill-library/status?owner_id=leo&source_prefix=gitea%3ALeo%2FArcrun%40', method: 'GET' }) + .reply(200, { success: true, pending: 8 }); + const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@', { + headers: KEY, + }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; pending: number }; + expect(data.success).toBe(true); + expect(data.pending).toBe(8); + }); +}); diff --git a/kbdb/src/actions/library-backfill.ts b/kbdb/src/actions/library-backfill.ts index bae0e31..111ea8d 100644 --- a/kbdb/src/actions/library-backfill.ts +++ b/kbdb/src/actions/library-backfill.ts @@ -29,6 +29,8 @@ // 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。 import type { Bindings } from '../types'; import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota'; +import { updateRecord } from './record-crud'; +import { ensureTripletLibrarySlot, DEFAULT_TRIPLET_TEMPLATE } from './library-map'; // IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用, // 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。 @@ -166,3 +168,130 @@ export async function libraryBackfillStatus( .first<{ c: number }>(); return { pending: row?.c ?? 0 }; } + +// ── 三元組(triplet)版:藏書地圖真正讀的那一半(Arcrun#87 二次收尾,2026-08-13)────────── +// +// 上面 backfillEntryLibraryTags 補的是 entries.metadata_json.$.library(卡片/搜尋/embed 層)。 +// 藏書地圖(library-map.ts 的 recomputeLibraryMap/liveTripletCountsByLibrary)讀的是**三元組 +// record 自己的 'library' slot**(entry_values,經 record-crud 的 updateRecord 寫入)——兩者是 +// 兩個互不相干的存放處(票上 2026-08-11 14:21 comment「庫值有兩個互不相干的存放處」段已釐清), +// 補了前者地圖依然是 0。 +// +// 既有通道只有「單筆 PATCH /kbdb/records/:id」(b6ef0f0,2026-08-11)——沒有批次版本。 +// 補標母體上千筆時逐筆 PATCH 不現實(也不安全:呼叫端要自己刻節流/冪等,容易漏做)。 +// 本函式是三元組版的批次 backfill,安全原則與上面 entries 版逐條對齊,不重新發明: +// - 呼叫端決定 library 值+篩選條件(base 對內容語意無知,不猜哪個 source_uri 該歸哪個庫) +// - 冪等:只選「目前沒有 library slot 值」的候選(NOT EXISTS 找缺 library 的那半,同 entries +// 版用「library 為空」而非覆蓋已標記過的) +// - D69 節流:與 entries 版、embed reconcile 共用同一顆每日 D1 寫入額度計數器(不共用會被繞過) +// - owner_id 必填(2026-08-11 leo 直令:批次改一大片既有資料不准無租戶範圍地掃) +// - 寫入沿用既有 updateRecord(record-crud.ts)——不手刻第二套 entry_values UPSERT SQL; +// 這條寫入路徑已經被 triplet-library-backfill.test.ts 驗證過語意正確(源頭順序/存量補標/ +// 冪等三案),本函式只是把它包成「呼叫端給 library+source_prefix,一次處理一批」的批次版。 +export interface TripletLibraryBackfillCriteria { + owner_id?: string; + triplet_template?: string; // 預設 DEFAULT_TRIPLET_TEMPLATE('triplet') + source_prefix?: string; // source_uri LIKE prefix%(本票的規則:^gitea:Leo/@ → 各庫;^kb:// → kb) +} + +function tripletCriteriaSql(c: TripletLibraryBackfillCriteria & { owner_id: string }): { where: string; params: unknown[] } { + // 候選:這個 template 底下、有 source_uri 值、owner 符合、source_uri 符合前綴、 + // 且目前這個 record 沒有任何 'library' slot 值的 record_id(NOT EXISTS 保冪等)。 + const conds = [ + 'ev.slot_name = ?', + 'e.content LIKE ? || \'%\'', + 'e.owner_id = ?', + `NOT EXISTS (SELECT 1 FROM entry_values lev WHERE lev.record_id = ev.record_id AND lev.slot_name = 'library')`, + ]; + const params: unknown[] = ['source_uri', c.source_prefix ?? '', c.owner_id]; + return { where: conds.join(' AND '), params }; +} + +/** + * 對「符合條件、目前未標記 library」的既有三元組 record 批次補上 target library 值。 + * 冪等 + 分批(單次 limit 上限)+ budget(與 entries 版/embed reconcile 共用每日 D1 寫入額度)。 + * 呼叫端(daemon/來源標籤系統/#87)決定「這批 source_uri 前綴對應哪個 library」, + * 本函式只負責安全、節流地把值寫進三元組 record(base 不猜語意,同 backfillEntryLibraryTags)。 + */ +export async function backfillTripletLibraryTags( + db: D1Database, + env: Pick, + opts: { library: string; owner_id: string; limit?: number } & TripletLibraryBackfillCriteria, +): Promise { + const library = (opts.library ?? '').trim(); + if (!library) throw new Error('library required'); + const ownerId = (opts.owner_id ?? '').trim(); + if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)'); + const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP); + const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE; + + // 冪等地確保 template 有 library slot(同 recomputeLibraryMap 的既有慣例,不動表)。 + await ensureTripletLibrarySlot(db, tripletTemplateName); + + const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>(); + if (!tpl) throw new Error(`triplet template not found: ${tripletTemplateName}`); + + const sel = tripletCriteriaSql({ ...opts, owner_id: ownerId }); + const res = await db + .prepare( + `SELECT ev.record_id AS id FROM entry_values ev JOIN entries e ON ev.entry_id = e.id + WHERE ev.template_id = ? AND ${sel.where} ORDER BY ev.record_id ASC LIMIT ?`, + ) + .bind(tpl.id, ...sel.params, limit) + .all<{ id: string }>(); + const scannedIds = (res.results ?? []).map((r) => r.id); + const scanned = scannedIds.length; + + // D69:額度截斷——每個候選最多 1 次 D1 write(updateRecord 對「缺 slot」的 grow 路徑正是 1 次 + // INSERT),與 entries 版/reconcile 共用同一顆計數器。 + const budget = await maintenanceBudgetToday(env, db); + const ids = scannedIds.slice(0, budget.remaining); + const quotaExceeded = scanned > ids.length; + + let tagged = 0; + for (const id of ids) { + const updated = await updateRecord(db, id, { library }); + if (updated) tagged += 1; + } + + try { + await addMaintenanceUsage(db, tagged); + } catch { + // fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。 + } + + const remRow = await db + .prepare( + `SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`, + ) + .bind(tpl.id, ...sel.params) + .first<{ c: number }>(); + + return { + library, + scanned, + tagged, + remaining: remRow?.c ?? 0, + quota_limit: budget.limit, + quota_used_today: budget.used + tagged, + quota_exceeded: quotaExceeded, + }; +} + +/** 待補標統計(三元組版,回報用):符合條件、目前未標記 library 的三元組筆數。 */ +export async function tripletLibraryBackfillStatus( + db: D1Database, + opts: TripletLibraryBackfillCriteria & { owner_id: string }, +): Promise<{ pending: number }> { + const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE; + const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>(); + if (!tpl) return { pending: 0 }; + const sel = tripletCriteriaSql(opts); + const row = await db + .prepare( + `SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`, + ) + .bind(tpl.id, ...sel.params) + .first<{ c: number }>(); + return { pending: row?.c ?? 0 }; +} diff --git a/kbdb/src/routes/records.ts b/kbdb/src/routes/records.ts index 1758200..3d84aee 100644 --- a/kbdb/src/routes/records.ts +++ b/kbdb/src/routes/records.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud'; +import { backfillTripletLibraryTags, tripletLibraryBackfillStatus } from '../actions/library-backfill'; export const recordRoutes = new Hono<{ Bindings: Bindings }>(); @@ -57,6 +58,54 @@ recordRoutes.get('/by-template/:template', async (c) => { return c.json({ success: true, records, count: records.length }); }); +// POST /records/backfill-library — 三元組版標庫補存量(Arcrun#87 二次收尾,2026-08-13)。 +// entries 版(POST /entries/backfill-library,Arcrun#85)補的是卡片層 metadata_json.library; +// 藏書地圖讀的是三元組 record 自己的 'library' slot,兩者是不同存放處——本端點補後者, +// 地圖(GET /map)才會真的從 0 變成有意義的數字。 +// body(必填 library + owner_id):{ library, owner_id, triplet_template?(預設 'triplet'), +// source_prefix?(必要篩選:本票的規則=gitea:Leo/@ 前綴 → 對應庫;kb:// → 'kb'), +// limit?(1-500,預設100) }。 +// 冪等:只選「目前沒有 library slot 值」的三元組;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。 +// budget:與 /entries/backfill-library、/embed/reconcile 共用同一顆每日 D1 寫入額度(D69)。 +// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。 +recordRoutes.post('/backfill-library', async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { + library?: string; + owner_id?: string; + triplet_template?: string; + source_prefix?: string; + limit?: number | string; + }; + const library = String(body.library ?? '').trim(); + const ownerId = String(body.owner_id ?? '').trim(); + if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400); + try { + const result = await backfillTripletLibraryTags(c.env.DB, c.env, { + library, + owner_id: ownerId, + triplet_template: body.triplet_template || undefined, + source_prefix: body.source_prefix || undefined, + limit: body.limit !== undefined ? Number(body.limit) : undefined, + }); + return c.json({ success: true, ...result }); + } catch (e) { + return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400); + } +}); + +// GET /records/backfill-library/status?owner_id=&triplet_template=&source_prefix= +// — 符合條件、目前未標記 library 的三元組筆數(backfill 前後都能查,判斷還剩多少)。 +recordRoutes.get('/backfill-library/status', async (c) => { + const ownerId = c.req.query('owner_id') || ''; + if (!ownerId) return c.json({ success: false, error: 'owner_id 必填' }, 400); + const status = await tripletLibraryBackfillStatus(c.env.DB, { + owner_id: ownerId, + triplet_template: c.req.query('triplet_template') || undefined, + source_prefix: c.req.query('source_prefix') || undefined, + }); + return c.json({ success: true, ...status }); +}); + // GET /records/:recordId recordRoutes.get('/:recordId', async (c) => { const rec = await getRecord(c.env.DB, c.req.param('recordId')); diff --git a/kbdb/tests/triplet-library-batch-backfill.test.ts b/kbdb/tests/triplet-library-batch-backfill.test.ts new file mode 100644 index 0000000..85e98b4 --- /dev/null +++ b/kbdb/tests/triplet-library-batch-backfill.test.ts @@ -0,0 +1,164 @@ +// 三元組版標庫「批次」backfill(Arcrun#87 二次收尾,2026-08-13)測試。 +// +// 背景:kbdb/tests/triplet-library-backfill.test.ts(2026-08-11)已經證明「逐筆 updateRecord +// 補標庫」這條寫入路徑語意正確(源頭順序/存量補標/冪等三案)。但那份測試操作的是逐筆手刻的 +// backfillPass() helper,不是可對外呼叫的批次能力——通道只有單筆 PATCH /kbdb/records/:id +// (b6ef0f0),母體上千筆時逐筆 PATCH 給呼叫端自己刻節流/冪等不現實。本檔驗證新加的 +// backfillTripletLibraryTags(kbdb/src/actions/library-backfill.ts):把「呼叫端給 library +// + source_prefix,一次處理一批」包成一個安全、節流的函式,語意與 entries 版 +// (library-backfill.test.ts)逐條對齊。 +// +// 覆蓋: +// 1. 只補「符合 source_prefix、目前未標記 library」的候選三元組;已標記的不動(冪等) +// 2. owner_id 必填(缺了要拋錯,同 2026-08-11 leo 直令) +// 3. 與 entries 版 backfillEntryLibraryTags 共用同一顆每日 D1 寫入額度(D69:兩者都要受管, +// 任一邊獨立跑就是把另一邊的閘繞過去) +// 4. status 欄位存在的 record 也一起處理(backfill 不管 active/superseded,那是地圖聚合層的 +// 篩選責任,不是補標層的責任——見 library-map.ts withLib 的 COALESCE 判準) +// +// 測試手法沿 triplet-library-backfill.test.ts/library-backfill.test.ts 慣例:真 node:sqlite +// 套 migrations/0001_base.sql 原檔(本檔只用得到 templates/entries/entry_values 三張表, +// 不涉 library_map,不需 0003),kbdb-sql-ok 行尾標記同既有慣例(測試治具本身,非牆外業務邏輯繞過 API)。 +import { describe, it, expect } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync } from 'node:fs'; +import { backfillTripletLibraryTags, tripletLibraryBackfillStatus } from '../src/actions/library-backfill'; +import { backfillEntryLibraryTags } from '../src/actions/library-backfill'; +import { createTemplate, createRecord, getRecord } from '../src/actions/record-crud'; +import type { Bindings } from '../src/types'; + +function makeSqliteD1(): D1Database { + const raw = new DatabaseSync(':memory:'); + raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔 + 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:測試治具(node:sqlite→D1 shim) + async first() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim) + async run() { + const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API + return { success: true, meta: { changes: r.changes } }; + }, + }; + return s; + } + return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; +} + +function makeEnv(db: D1Database, opts: { maintenanceLimit?: string } = {}): Bindings { + return { + DB: db, + ENVIRONMENT: 'test', + KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit, + } as unknown as Bindings; +} + +// prod 實際 triplet template 的 slots(library-map.test.ts/triplet-library-backfill.test.ts 同款常數)。 +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' }); +} + +describe('backfillTripletLibraryTags — 批次補三元組的 library slot(藏書地圖真正讀的那一半)', () => { + it('只補符合 source_prefix、目前缺 library 的三元組;owner 不符/已標記過的不動', async () => { + const db = makeSqliteD1(); + await seedTripletTemplate(db); + const env = makeEnv(db); + + const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/Arcrun@a.md' }, owner_id: 'bfezv28v' }); + const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/Arcrun@b.md' }, owner_id: 'bfezv28v' }); + // 別的 owner,同前綴——不該被補到(跨租戶隔離)。 + const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'gitea:Leo/Arcrun@c.md' }, owner_id: 'someone-else' }); + // 不符 source_prefix——不該被補到。 + const r4 = await createRecord(db, { template: 'triplet', values: { subject: 'G', predicate: 'r', object: 'H', source_uri: 'gitea:Leo/mira@d.md' }, owner_id: 'bfezv28v' }); + + const result = await backfillTripletLibraryTags(db, env, { + library: 'arcrun', + owner_id: 'bfezv28v', + source_prefix: 'gitea:Leo/Arcrun@', + }); + + expect(result.scanned).toBe(2); + expect(result.tagged).toBe(2); + expect(result.remaining).toBe(0); + + const rec1 = await getRecord(db, r1.record_id); + const rec2 = await getRecord(db, r2.record_id); + const rec3 = await getRecord(db, r3.record_id); + const rec4 = await getRecord(db, r4.record_id); + expect(rec1!.values.library).toBe('arcrun'); + expect(rec2!.values.library).toBe('arcrun'); + expect(rec3!.values.library).toBeUndefined(); // 別的 owner,沒被動到 + expect(rec4!.values.library).toBeUndefined(); // 不符前綴,沒被動到 + }); + + it('冪等:已標記過 library 的三元組不會被第二輪 touch(不會覆蓋成別的值)', async () => { + const db = makeSqliteD1(); + await seedTripletTemplate(db); + const env = makeEnv(db); + + const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'bfezv28v' }); + + const first = await backfillTripletLibraryTags(db, env, { library: 'kb', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/kb@' }); + expect(first.tagged).toBe(1); + + // 第二輪同條件再跑一次:這筆已經有 library 值了,不該再被選中,即使再給一個不同的 library 值。 + const second = await backfillTripletLibraryTags(db, env, { library: 'something-else', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/kb@' }); + expect(second.scanned).toBe(0); + expect(second.tagged).toBe(0); + + const rec1 = await getRecord(db, r1.record_id); + expect(rec1!.values.library).toBe('kb'); // 沒被第二輪的 'something-else' 洗掉 + }); + + it('owner_id 必填 — 缺了直接拋錯,不准無租戶範圍地掃全庫', async () => { + const db = makeSqliteD1(); + await seedTripletTemplate(db); + const env = makeEnv(db); + await expect(backfillTripletLibraryTags(db, env, { library: 'arcrun', owner_id: '' })).rejects.toThrow(/owner_id required/); + }); + + it('與 entries 版 backfillEntryLibraryTags 共用同一顆每日 D1 寫入額度(D69:任一邊都不能繞過去)', async () => { + const db = makeSqliteD1(); + await seedTripletTemplate(db); + const env = makeEnv(db, { maintenanceLimit: '3' }); // 每日只剩 3 次背景維護寫入額度 + + // entries 版先用掉 2 筆額度。 + await db.prepare(`INSERT INTO entries (id, content, entry_type, owner_id, created_at, updated_at) VALUES (?, 'x', 'block', 'bfezv28v', 1000, 1000)`).bind('e1').run(); // kbdb-sql-ok:測試治具灌資料 + await db.prepare(`INSERT INTO entries (id, content, entry_type, owner_id, created_at, updated_at) VALUES (?, 'x', 'block', 'bfezv28v', 1000, 1000)`).bind('e2').run(); // kbdb-sql-ok:測試治具灌資料 + const entriesResult = await backfillEntryLibraryTags(db, env, { library: 'notes', owner_id: 'bfezv28v' }); + expect(entriesResult.tagged).toBe(2); + + // 三元組版此時只剩 1 次額度可用,即使候選有 2 筆,也只能補 1 筆、如實回報 quota_exceeded。 + await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/Arcrun@a.md' }, owner_id: 'bfezv28v' }); + await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/Arcrun@b.md' }, owner_id: 'bfezv28v' }); + const tripletResult = await backfillTripletLibraryTags(db, env, { library: 'arcrun', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/Arcrun@' }); + + expect(tripletResult.scanned).toBe(2); + expect(tripletResult.tagged).toBe(1); // 只剩 1 額度,被截斷 + expect(tripletResult.quota_exceeded).toBe(true); + expect(tripletResult.remaining).toBe(1); // 還有 1 筆沒補到 + }); + + it('tripletLibraryBackfillStatus 回報還剩多少待補(backfill 前後都能查)', async () => { + const db = makeSqliteD1(); + await seedTripletTemplate(db); + const env = makeEnv(db); + + await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/notes@a.md' }, owner_id: 'bfezv28v' }); + await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/notes@b.md' }, owner_id: 'bfezv28v' }); + + const before = await tripletLibraryBackfillStatus(db, { owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' }); + expect(before.pending).toBe(2); + + await backfillTripletLibraryTags(db, env, { library: 'notes', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' }); + + const after = await tripletLibraryBackfillStatus(db, { owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' }); + expect(after.pending).toBe(0); + }); +}); -- 2.52.0