// 三元組版標庫「批次」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); }); });