@@ -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 < T > ( ) { return { results : raw.prepare ( sql ) . all ( . . . params ) as T [ ] } ; } , // kbdb-sql-ok: 記憶體測試替身
async first < T > ( ) { 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 < void > {
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 < number > {
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' ) ;
} ) ;
} ) ;