179dd60571
藏書地圖(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 <noreply@anthropic.com>
165 lines
9.9 KiB
TypeScript
165 lines
9.9 KiB
TypeScript
// 三元組版標庫「批次」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<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||
async first<T>() { 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<void> {
|
||
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);
|
||
});
|
||
});
|