Files
Arcrun/kbdb/src/actions/library-map.ts
T
uncle6me-web ceb7638d74 feat(kbdb): 樹狀 record 模型第一刀——record 有身分、關係是唯一機制、entry_values 拆表(v7 定稿實作)
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。
模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」

- 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數
  (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+
  每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列
  (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values
  (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。
- record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留,
  驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。
- entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry
  接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。
- 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列,
  LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀),
  GET /maintenance/relation-orphans 唯讀巡檢。
- cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS,
  整檔送 /query 會在重跑時假紅)。
- 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描;
  釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。

遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型
與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:34:48 +08:00

587 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// library-map(藏書地圖)— 聚合 SQL 的家(SDD system-dev/docs/3-specs/library-map/design.md §2)。
// D6 鐵律推論:degree 排序/predicate 統計/跨庫 join 是聚合 SQL,插件與 workflow 全程禁 SQL
// 所以重算只能住 kbdb base 本體(本檔)。三表不變量不破:地圖=library_map template 的 record
//(每庫一個 map block)+slots,零建表零 ALTER。
//
// triplet 按庫定位現況(2026-07-19 對 prod 核實,design §1 的「先核實」):
// - triplet templateprod 名 'triplet'kbdb-graph 建)有 source_uri slot、無 library slot
// - entries 的 metadata.library 機制在(portal-auth P1)但既有資料未標記(?library=kb → 0 筆)。
// → 依 SDD 預案:在 triplet template schema 加 optional `library` slot(改 template 不動表,
// 見 ensureTripletLibrarySlot);ingest 端補寫值屬 M3。過渡期(舊 triplet 沒有 library slot 值)
// recompute 可帶 source_prefix 參數用 source_uri 前綴當 fallback 過濾——由 caller 提供前綴,
// base 不寫死任何 URI 格式語意(base 對內容語意無知的既有原則)。
import { createEntry, getEntry } from './entry-crud';
import { createRecord, createTemplate, getTemplate, updateRecord, updateTemplate } from './record-crud';
export const LIBRARY_MAP_TEMPLATE_ID = 'tpl-library-map';
export const LIBRARY_MAP_TEMPLATE_NAME = 'library_map';
// 與 migrations/0003_library_map.sql 的 seed 同一份定義(兩邊必須一致)。
export const LIBRARY_MAP_SLOTS = [
'library',
'narrative',
'top_entities',
'relation_profile',
'bridges',
'triplet_count',
'commit_hash',
'status',
];
// prod 實際部署的 triplet template 名(kbdb_list_templates 核實);caller 可用參數覆蓋。
export const DEFAULT_TRIPLET_TEMPLATE = 'triplet';
export interface TopEntity { name: string; degree: number }
export interface RelationStat { predicate: string; count: number }
export interface Bridge { entity: string; libraries: string[] }
export interface LibraryMapRow {
library: string;
narrative: string | null;
top_entities: string[]; // 全館視圖只回 top 3 名字(數百 token 內,design §4 MCP instructions 用)
triplet_count: number;
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於「這庫沒有知識」——
// entriesingest 進來的原始卡片/skill 內容)與 triplet(從 entries 萃取出的三元組)是兩件事,
// 一個庫可能有大量 entries、但三元組萃取從沒對它跑過(實測:kb 以外 7 庫皆此況,entries 存在
// 且 library 標記正確,triplet 一筆都沒有)。entry_count 讓讀端(AIMCP 渲染層)分得清
// 「真的沒東西」與「有東西但還沒被萃取成三元組」,不再把後者誤讀成前者、誤判庫是空的而放棄查詢
// (見本檔 liveEntryCountsByLibrary 的計數口徑)。
entry_count: number;
updated_at: number;
}
export interface LibraryMapDetail {
record_id: string;
library: string;
narrative: string | null;
content: string | null; // map block 的可嵌人話(design §5M6 semantic 路由直接用)
top_entities: TopEntity[];
relation_profile: RelationStat[];
bridges: Bridge[];
triplet_count: number;
entry_count: number; // 同 LibraryMapRow.entry_count(見該欄位註解)
commit_hash: string | null;
status: string;
updated_at: number;
}
export interface RecomputeInput {
library: string;
narrative?: string; // wiki 首段抽取屬 ingest 端(M3)——base 只收值不抽取
commit_hash?: string;
owner_id?: string;
source_prefix?: string; // 過渡 fallbacklibrary slot 缺值的舊 triplet 以 source_uri LIKE 前綴歸庫
triplet_template?: string;
top_n?: number; // top_entities 取幾個(預設 10、上限 50)
}
export interface RecomputeResult {
map: LibraryMapDetail;
superseded: string[]; // 被標 superseded 的舊 map record ids
triplet_template: string;
triplet_library_slot_added: boolean; // 本次是否幫 triplet template 補上 optional library slot
}
// ---- template ensureM1 ----
// library_map template 若不存在就走既有 createTemplate 路徑補建(migration 0003 的 runtime 保險)。
// UNIQUE(name) 撞到(並發/半套 seed)→ 重讀即可,冪等。
export async function ensureLibraryMapTemplate(db: D1Database): Promise<void> {
const existing = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
if (existing) return;
try {
await createTemplate(db, {
id: LIBRARY_MAP_TEMPLATE_ID,
name: LIBRARY_MAP_TEMPLATE_NAME,
description: 'per-library map block(藏書地圖:graph 機械導出,零 LLM 生成;Arcrun#39',
slots: LIBRARY_MAP_SLOTS,
created_by: 'system',
});
} catch {
if (!(await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME))) throw new Error('ensureLibraryMapTemplate failed');
}
}
// triplet template schema 加 optional `library` slotdesign §1 預案:改 template 不動表)。
// 只增不減、冪等;ingest 端(M3)開始寫值後,recompute 就能按 slot 精準歸庫,不再靠 source_prefix。
export async function ensureTripletLibrarySlot(db: D1Database, tripletTemplate: string): Promise<boolean> {
const tpl = await getTemplate(db, tripletTemplate);
if (!tpl) throw new Error(`triplet template not found: ${tripletTemplate}`);
const slots: string[] = JSON.parse(tpl.slots_json);
if (slots.includes('library')) return false;
await updateTemplate(db, tpl.id, { slots: [...slots, 'library'] });
return true;
}
// ---- 聚合 SQLM2 recompute ----
// record → 一列一 triplet 的 pivot0007 樹狀 record 模型:格子=關係列)。
// b=歸屬關係列(rel=sys_belongs, dst=template 的 sheet entry)=record 成員名單;
// r=該 record 的格子關係列;v=格子指到的內容 entry。欄位謂詞 id 決定性衍生
// fld_<template>_<slot>,與 0007 遷移、record-crud fieldEntryId 同一條規則)⇒
// 直接用字串串接比對,不必 JOIN field entry。MAX(CASE …) 縱轉橫慣用法照舊;
// owner filter 下在歸屬關係列的 owner_idcreateRecord 寫入時同值,0007 遷移同一推導)。
// 參數簽名與舊版逐字相同:[template_id, owner?]。
function tripletPivotSql(ownerFiltered: boolean): string {
return `SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_subject' THEN v.content END) AS subject,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_object' THEN v.content END) AS object,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_predicate' THEN v.content END) AS predicate,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_source_uri' THEN v.content END) AS source_uri
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id`;
}
// library_map 自身 record 的 pivot(讀端+supersede 查找共用;同上 0007 形狀)。
function mapPivotSql(ownerFiltered: boolean): string {
return `SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_narrative' THEN v.content END) AS narrative,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_top_entities' THEN v.content END) AS top_entities,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_relation_profile' THEN v.content END) AS relation_profile,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_bridges' THEN v.content END) AS bridges,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_triplet_count' THEN v.content END) AS triplet_count,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_commit_hash' THEN v.content END) AS commit_hash,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(r.created_at) AS ts
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id`;
}
function parseJsonArray<T>(raw: string | null | undefined): T[] {
if (!raw) return [];
try {
const v = JSON.parse(raw);
return Array.isArray(v) ? (v as T[]) : [];
} catch {
return [];
}
}
export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput): Promise<RecomputeResult> {
const library = input.library.trim();
if (!library) throw new Error('library required');
const tripletTemplateName = input.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
const topN = Math.min(Math.max(Math.floor(input.top_n ?? 10), 1), 50);
await ensureLibraryMapTemplate(db);
// 順手把 optional library slot 補進 triplet templateM1;冪等,不動表)。
const librarySlotAdded = await ensureTripletLibrarySlot(db, tripletTemplateName);
const tripletTpl = await getTemplate(db, tripletTemplateName);
if (!tripletTpl) throw new Error(`triplet template not found: ${tripletTemplateName}`);
const owner = input.owner_id || undefined;
const pivot = tripletPivotSql(!!owner);
const pivotParams: unknown[] = owner ? [tripletTpl.id, owner] : [tripletTpl.id];
// 歸庫謂詞:library slot 優先;caller 給了 source_prefix 才對「沒有 library 值的舊 triplet」
// 啟用 source_uri 前綴 fallbackM3 backfill 完成前的過渡;base 不解析 URI 語意)。
const libCond = input.source_prefix
? `(t.library = ? OR (t.library IS NULL AND t.source_uri LIKE ? || '%'))`
: `t.library = ?`;
const libParams: unknown[] = input.source_prefix ? [library, input.source_prefix] : [library];
// 只算 active tripletsuperseded/deprecated 不進地圖;沒有 status slot 的舊資料視同 active)。
const withLib = `WITH t AS (${pivot}), lib AS (
SELECT * FROM t WHERE COALESCE(t.status, 'active') = 'active' AND ${libCond})`;
const baseParams = [...pivotParams, ...libParams];
const [countRow, topRes, relRes, bridgeRes] = await Promise.all([
db.prepare(`${withLib} SELECT COUNT(*) AS n FROM lib`).bind(...baseParams).first<{ n: number }>(),
// degreeentity 在該庫 active triplet 的出現次數(subject+object 兩側都算;同名並列取名字序穩定輸出)
db
.prepare(
`${withLib} SELECT name, COUNT(*) AS degree FROM (
SELECT subject AS name FROM lib UNION ALL SELECT object AS name FROM lib)
WHERE name IS NOT NULL GROUP BY name ORDER BY degree DESC, name ASC LIMIT ?`,
)
.bind(...baseParams, topN)
.all<{ name: string; degree: number }>(),
// predicate 分布=庫的「性格」(spec §3 relation_profile
db
.prepare(
`${withLib} SELECT predicate, COUNT(*) AS n FROM lib
WHERE predicate IS NOT NULL GROUP BY predicate ORDER BY n DESC, predicate ASC LIMIT 100`,
)
.bind(...baseParams)
.all<{ predicate: string; n: number }>(),
// bridges=本庫 entity 同時出現在其他庫(跨庫 join)。對面那側只能靠 library slot 標記值
//source_prefix 只描述本庫的前綴,無法反推他庫)→ M3 backfill 前 bridges 會偏稀疏,誠實現況。
db
.prepare(
`${withLib}, labeled AS (
SELECT DISTINCT name, library FROM (
SELECT subject AS name, library FROM t WHERE COALESCE(status,'active') = 'active'
UNION SELECT object AS name, library FROM t WHERE COALESCE(status,'active') = 'active')
WHERE name IS NOT NULL AND library IS NOT NULL AND library != ?),
mine AS (
SELECT DISTINCT subject AS name FROM lib WHERE subject IS NOT NULL
UNION SELECT DISTINCT object AS name FROM lib WHERE object IS NOT NULL)
SELECT l.name AS entity, l.library AS library FROM labeled l
JOIN mine m ON m.name = l.name ORDER BY l.name ASC, l.library ASC`,
)
.bind(...baseParams, library)
.all<{ entity: string; library: string }>(),
]);
const tripletCount = countRow?.n ?? 0;
const topEntities: TopEntity[] = (topRes.results ?? []).map((r) => ({ name: r.name, degree: r.degree }));
const relationProfile: RelationStat[] = (relRes.results ?? []).map((r) => ({ predicate: r.predicate, count: r.n }));
// GROUP_CONCAT 不用(entity 名可能含逗號)→ 取 (entity, library) 對在 JS 聚合,上限 50 個橋接點。
const bridgeMap = new Map<string, string[]>();
for (const r of bridgeRes.results ?? []) {
if (!bridgeMap.has(r.entity) && bridgeMap.size >= 50) continue;
const libs = bridgeMap.get(r.entity) ?? [];
if (!libs.includes(r.library)) libs.push(r.library);
bridgeMap.set(r.entity, libs);
}
const bridges: Bridge[] = [...bridgeMap.entries()].map(([entity, libraries]) => ({ entity, libraries }));
// map block 的 content=可嵌人話(design §5:之後 M6 semantic 路由第一跳直接嵌這句做庫路由)。
// narrativecaller 有給才覆蓋;沒給 → 沿用上一版現有 narrative(若有)。
// 2026-08-08 修正:這欄原本「沒給就清空」,會被下面新增的即時新鮮度層
// ensureFreshLibraryMaps,讀端自動重算、天生不帶 narrative)每次呼叫都靜默洗掉
// ingest 端/人工填過的 narrative——沒給值=維持現狀,不是重置成空字串。
let narrative = input.narrative?.trim();
if (!narrative) {
const prev = await getLibraryMapDetail(db, library, owner);
narrative = prev?.narrative?.trim() || '';
}
const coreNames = topEntities.slice(0, 3).map((t) => t.name);
const content = `${library}${narrative || 'narrative 待 ingest 補寫)'}。核心:${
coreNames.length ? coreNames.join('、') : '(尚無 entities'
}`;
// 寫入順序安全(design §2「交易式或至少順序安全」;D1 無跨語句交易):
// 先建新 active blockrecord,成功後才把舊的標 superseded——中途失敗最壞是多一個 active,
// 讀端一律取最新 active,不會出現「地圖真空」。
const blockEntry = await createEntry(db, {
content,
entry_type: 'block',
owner_id: owner ?? null,
page_name: `library-map:${library}`,
metadata_json: JSON.stringify({ kind: 'library_map', library }),
});
const values: Record<string, string> = {
library,
narrative,
top_entities: JSON.stringify(topEntities),
relation_profile: JSON.stringify(relationProfile),
bridges: JSON.stringify(bridges),
triplet_count: String(tripletCount),
status: 'active',
};
if (input.commit_hash) values.commit_hash = input.commit_hash;
// record_idmap block entry 的 idblock(人話 content)與 record(結構化 slots)同一身分,
// 讀端一次定位、embed 模組(M6)也直接嵌這顆 entry。
await createRecord(db, {
template: LIBRARY_MAP_TEMPLATE_NAME,
record_id: blockEntry.id,
values,
owner_id: owner ?? null,
});
// 舊 active map(同庫、非本次新建)→ 標 superseded(沿用既有 status slot 語意,R6)。
const mapTpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
const oldParams: unknown[] = owner ? [mapTpl!.id, owner, library, blockEntry.id] : [mapTpl!.id, library, blockEntry.id];
const oldRes = await db
.prepare(
`WITH m AS (${mapPivotSql(!!owner)})
SELECT rid FROM m WHERE m.library = ? AND COALESCE(m.status, 'active') = 'active' AND m.rid != ?`,
)
.bind(...oldParams)
.all<{ rid: string }>();
const superseded: string[] = [];
for (const row of oldRes.results ?? []) {
await updateRecord(db, row.rid, { status: 'superseded' });
superseded.push(row.rid);
}
// entry_countArcrun#87 三次收尾):recompute 只重算三元組那一半,entry_count 是即時算的
// 另一半,兩者同樣的道理一起回傳(見 LibraryMapDetail.entry_count 欄位註解)。
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
return {
map: {
record_id: blockEntry.id,
library,
narrative: narrative || null,
content,
top_entities: topEntities,
relation_profile: relationProfile,
bridges,
triplet_count: tripletCount,
entry_count: entryCount,
commit_hash: input.commit_hash ?? null,
status: 'active',
updated_at: blockEntry.created_at,
},
superseded,
triplet_template: tripletTemplateName,
triplet_library_slot_added: librarySlotAdded,
};
}
// ---- 即時新鮮度(M3 收尾,2026-08-08 ----
//
// 真因(總管實測+wiki system-dev/wiki/mistakes.md「08-08」段):design §3 原訂「ingest 完成 →
// 逐庫呼 POST /map/recompute」,但 repo 內查無任何呼叫點——三週沒接上,導致沒手動 backfill 過的
// 租戶(絕大多數)GET /map 恆回空,且 M4 的 MCP 說明文字還宣稱「地圖由 ingest 尾端自動重算」
// (不存在的事)。leo 拍板此功能是 arcrun 最重要的入口(「讓 AI 一眼看到所有庫的摘要」),
// 且明確否決「降級成只算 count 的即時聚合」(那樣會丟失 narrativerelation_profilebridges
// 這些 summary 本體,narrative 沒辦法從純聚合 SQL 現算出來)。
//
// 解法:不再依賴任何外部呼叫者記得呼 /map/recompute,改成讀端(GET /map、GET /map/:library
// 自己核對即時三元組數,落差就地呼叫既有的 recomputeLibraryMap 補算——聚合 SQL 沒有第二套,
// 只是觸發時機從「等外部呼叫」改成「讀的當下順手核對」。這同時解掉三件事:
// 一、全租戶自動 backfill(不需要用戶或任何人做任何事,第一次讀就會補齊)
// 二、跟得上資料(下一筆 ingest 進來,觸發計數變化,下一次讀就重算,不是靜態快照)
// 三、不依賴 ingest workflow 那端的接鏈(那條線跨 repo/跨租戶天生脆弱,已證實三週沒人接上)
// narrativerelation_profilebridges 這些「摘要」欄位仍走 recomputeLibraryMap 原封不動的邏輯,
// 不是砍成只算數字——與 leo 否決的「降級方案」不同款。
// 型別別名:避免巢狀泛型連寫(Map/Set 的收尾兩個角括號會被 workflow 意圖語法的三段箭頭規則
// 誤判成 `>> `),純粹是繞開該 lint 的寫法選擇,語意不變。
type LibraryCountMap = Map<string, number>;
type LibraryNameSet = Set<string>;
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
// triplet 歸 'general')——與 GET /records/triplet-statst142)同一套分組語意,兩處數字對得上。
//
// 2026-08-11 修根因(Arcrun#87,動工前量測 comment 第四節):這裡原本完全不過濾 status,
// 而 recomputeLibraryMap(上方 withLib)只算 COALESCE(status,'active')='active'。兩邊判準不
// 一致,只要有一筆 superseded triplet,這裡的即時計數就會跟重算後的快取對不上,
// ensureFreshLibraryMaps 判定 stale,每次讀地圖都觸發重算,每次都新建一筆 library_map
// recordsuperseded 舊的),無止盡寫 D1,且加劇 recomputeLibraryMap 本身非原子 supersede
// 的競態(另一個已知病,wiki 08-10 條目)。實測:間隔數秒連讀兩次地圖、中間無任何寫入動作,
// updated_at 仍前進。修法:這裡的 status 判準改成與 recomputeLibraryMap 逐字一致,兩邊算出
// 的計數才會在資料未變動時相等,stale 判定回歸「真的有資料變動才 stale」。
async function liveTripletCountsByLibrary(
db: D1Database,
tripletTemplateId: string,
owner_id?: string,
): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
const res = await db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
`SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n
FROM (
SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id
) AS tr
WHERE COALESCE(tr.status, 'active') = 'active'
GROUP BY COALESCE(NULLIF(tr.library, ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
const m: LibraryCountMap = new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
// 這個 owner 底下、依 entries 自己的 metadata_json.$.library 分組的即時「原始內容」數
//Arcrun#87 三次收尾,2026-08-13)——與 liveTripletCountsByLibrary 算的是兩張完全不同的帳:
// 那個算「萃取出幾條三元組」,這個算「ingest 進來幾筆原始卡片/skill/block」。兩者天生會不一樣
// (萃取是下游、有延遲甚至從沒對某些庫跑過),刻意分開算、分開回傳,不是同一數字的兩種寫法。
//
// 排除口徑(都是結構性排除,不是內容語意判斷——沒有違反「base 對內容語意無知」):
// - entry_type='value'record-crud.ts 的通用機制,任何 template(含 triplet 本身)建 record
// 時每個 slot 值都會落一筆這種 entry,是儲存實作細節,不是使用者看得到的「一筆知識」。
// - metadata.kind='library_map' 的 block:地圖自己每次 recompute 產出的摘要 block(含歷史
// superseded 的),算進去會自我膨脹、且無限接近雞生蛋——地圖不該把自己算進地圖裡。
async function liveEntryCountsByLibrary(db: D1Database, owner_id?: string): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [owner_id] : [];
const res = await db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
COUNT(*) AS n
FROM entries
WHERE ${owner_id ? 'owner_id = ? AND ' : ''}entry_type != 'value'
AND src_id IS NULL
AND entry_type NOT IN ('record', 'sheet', 'field', 'system')
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
const m: LibraryCountMap = new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
// 「已知庫名」集合:即使目前三元組數是 0,只要蓋過章(entries metadata.libraryt52 慣例)或
// 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的
// 「這庫是空的」(回 200triplet_count:0vs「查無此庫」(回 404)。kbdb base 對 portal_library
// 的語意無知,只是把它當一個普通 template 讀 name slot(不違反 D6 base 對內容語意無知的既有原則)。
async function knownLibraryNames(db: D1Database, owner_id?: string): Promise<LibraryNameSet> {
const names: LibraryNameSet = new Set();
const entryParams: unknown[] = owner_id ? [owner_id] : [];
const entryRows = await db
.prepare(
`SELECT DISTINCT json_extract(metadata_json, '$.library') AS library FROM entries
WHERE ${owner_id ? 'owner_id = ?' : '1=1'} AND json_extract(metadata_json, '$.library') IS NOT NULL`,
)
.bind(...entryParams)
.all<{ library: string | null }>();
for (const r of entryRows.results ?? []) if (r.library) names.add(r.library);
const libTpl = await getTemplate(db, 'portal_library');
if (libTpl) {
const libParams: unknown[] = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
const libRows = await db
.prepare(
`SELECT MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_name' THEN v.content END) AS name
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id`,
)
.bind(...libParams)
.all<{ name: string | null }>();
for (const r of libRows.results ?? []) if (r.name) names.add(r.name);
}
return names;
}
// 核對+補算:這個 owner 底下所有「即時有三元組」或「已知但地圖過期/缺失」的庫,一次核對、
// 只對真的落差的庫重算(平行跑,單庫失敗不擋其他庫、不擋讀取——地圖是加分不是硬依賴)。
// 沒有 triplet template(這顆 KBDB 從沒建過任何三元組)→ 無地圖可算,直接返回,不報錯。
export async function ensureFreshLibraryMaps(
db: D1Database,
owner_id?: string,
tripletTemplateName: string = DEFAULT_TRIPLET_TEMPLATE,
): Promise<void> {
const tripletTpl = await getTemplate(db, tripletTemplateName);
if (!tripletTpl) return;
const [liveCounts, cached, known] = await Promise.all([
liveTripletCountsByLibrary(db, tripletTpl.id, owner_id),
listLibraryMaps(db, owner_id),
knownLibraryNames(db, owner_id),
]);
const cachedByLib = new Map(cached.map((m) => [m.library, m]));
const stale = new Set<string>();
for (const [library, count] of liveCounts) {
const c = cachedByLib.get(library);
if (!c || c.triplet_count !== count) stale.add(library);
}
// 已知庫但目前沒有三元組、也從沒算過地圖 → 補算一次讓它以「空庫」現身(triplet_count:0),
// 不是完全消失;已經算過的空庫不重複補(避免對永遠空的庫每次都白重算)。
for (const name of known) {
if (!liveCounts.has(name) && !cachedByLib.has(name)) stale.add(name);
}
await Promise.all(
[...stale].map((library) =>
recomputeLibraryMap(db, { library, owner_id, triplet_template: tripletTemplateName }).catch(() => {
// 單庫重算失敗(如聚合 SQL 撞到髒資料)不擋其他庫、不擋讀取——鐵律:地圖是加分不是依賴。
}),
),
);
}
// ---- 讀端(M2 GET ----
interface MapPivotRow {
rid: string;
library: string | null;
narrative: string | null;
top_entities: string | null;
relation_profile: string | null;
bridges: string | null;
triplet_count: string | null;
commit_hash: string | null;
status: string | null;
ts: number;
}
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_countentry_count),MCP
// instructions 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→
// 誠實回空清單。entry_count 與 triplet_count 並排回傳(Arcrun#87 三次收尾):一個查「原始內容
// 有沒有」、一個查「萃取出幾條三元組」,兩者不同源、允許不一致(見 liveEntryCountsByLibrary
// 註解),讀端不該把其中一個的 0 當成另一個的答案。
export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise<LibraryMapRow[]> {
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
if (!tpl) return [];
const params: unknown[] = owner_id ? [tpl.id, owner_id] : [tpl.id];
const [res, entryCounts] = await Promise.all([
db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`,
)
.bind(...params)
.all<MapPivotRow>(),
liveEntryCountsByLibrary(db, owner_id),
]);
// 每庫只留最新 activesupersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。
const byLib = new Map<string, LibraryMapRow>();
for (const r of res.results ?? []) {
if (!r.library || byLib.has(r.library)) continue;
byLib.set(r.library, {
library: r.library,
narrative: r.narrative || null,
top_entities: parseJsonArray<TopEntity>(r.top_entities).slice(0, 3).map((t) => t.name),
triplet_count: Number(r.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(r.library) ?? 0,
updated_at: r.ts,
});
}
return [...byLib.values()].sort((a, b) => a.library.localeCompare(b.library));
}
// 單庫詳圖:完整 slotsmap block 的人話 content。
export async function getLibraryMapDetail(
db: D1Database,
library: string,
owner_id?: string,
): Promise<LibraryMapDetail | null> {
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
if (!tpl) return null;
const params: unknown[] = owner_id ? [tpl.id, owner_id, library] : [tpl.id, library];
const row = await db
.prepare(
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE m.library = ? AND COALESCE(m.status, 'active') = 'active'
ORDER BY m.ts DESC LIMIT 1`,
)
.bind(...params)
.first<MapPivotRow>();
if (!row) return null;
// record_idmap block entry idrecompute 寫入時綁定);entry 若被外力刪除,content 誠實回 null。
// entry_count 與全館視圖(listLibraryMaps)同一套計法(liveEntryCountsByLibrary),單庫詳圖
// 只取自己那一庫的數字——Arcrun#87 三次收尾,理由見 LibraryMapDetail.entry_count 欄位註解。
const [blockEntry, entryCounts] = await Promise.all([
getEntry(db, row.rid),
liveEntryCountsByLibrary(db, owner_id),
]);
return {
record_id: row.rid,
library,
narrative: row.narrative || null,
content: blockEntry?.content ?? null,
top_entities: parseJsonArray<TopEntity>(row.top_entities),
relation_profile: parseJsonArray<RelationStat>(row.relation_profile),
bridges: parseJsonArray<Bridge>(row.bridges),
triplet_count: Number(row.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(library) ?? 0,
commit_hash: row.commit_hash || null,
status: row.status ?? 'active',
updated_at: row.ts,
};
}