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>
This commit is contained in:
uncle6me-web
2026-08-15 21:34:48 +08:00
parent 969acff325
commit ceb7638d74
25 changed files with 799 additions and 231 deletions
+45
View File
@@ -721,6 +721,22 @@ export async function downloadAndDeploy(
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql${execLogMigPath}`);
}
// 3.8 樹狀 record 模型(0007v7 定稿 2026-08-15):record 有身分、關係是唯一機制、
// entry_values 拆表。**必須排在所有 template seed 之後**(它把 templates 表既有列
// 鏡射成池中 sheet/field entry)。逐句套用+容錯 duplicate column:檔內三句
// ADD COLUMN 在 SQLite 沒有 IF NOT EXISTS 形式,重跑(每次部署都會重跑本段)時
// 那三句報 duplicate column = 已套用,其餘語句全部語句級冪等(檔頭有完整說明)。
const treeMigPath = join(root, 'kbdb', 'migrations', '0007_tree_record_model.sql');
if (existsSync(treeMigPath)) {
try {
await applyD1MigrationTolerant(ctx, readFileSync(treeMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0007_tree_record_model (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0007_tree_record_model.sql${treeMigPath}`);
}
}
const cypherExecutorUrl = ctx.workerSubdomain
@@ -751,6 +767,35 @@ export async function downloadAndDeploy(
};
}
/**
* 逐句套 migration,容錯 duplicate column0007 專用)。
*
* 為什麼不能走 applyD1Migration 整檔送:/query 端點任何一句失敗整批中止——
* 0007 的三句 ADD COLUMN 在重跑時必然報 duplicate columnSQLite 沒有欄位級
* IF NOT EXISTS),整檔送 ⇒ 第二次部署起 migration 永遠假紅、後面的資料搬遷
* 語句永遠不被執行。逐句+把 duplicate column 視為「已套用」,其餘錯誤照樣拋。
* 切句手法與安裝器 compile-migrations.mjs 同款(剝 -- 註解、依分號切;
* 0007 的字串常值不含分號,前提成立)。
*/
async function applyD1MigrationTolerant(ctx: DeployContext, sql: string): Promise<void> {
const statements = sql
.split('\n')
.map((l) => l.replace(/--.*$/, ''))
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const stmt of statements) {
try {
await applyD1Migration(ctx, stmt);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用
throw new Error(`${stmt.slice(0, 60)}… → ${msg}`);
}
}
}
/**
* 對 D1 套 SQL migration(透過 CF API `/d1/database/{id}/query`,非 wrangler)。
* 用 init 已驗的 ctx.apiToken + accountIdquery 端點接受多語句檔,一次送整份 0001_base.sql。
+138
View File
@@ -0,0 +1,138 @@
-- 0007 — record 有身分,關係是唯一機制(樹狀 record 模型第一刀)
-- 規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm
--
-- 模型一句話(leo 定案):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」
-- · record = 池中一顆有身分的 entry(沿用原 record_id 字串當 id,既有引用不失效)
-- · 欄位是關係、歸屬也是關係——同一種機制:一列關係 = src/rel/dst 三個型別化指標欄
-- (「池上型別化指標欄」,parent_id 是這族欄位的既有先例。紅線:不准用關係實作關係、
-- 不准把指標塞回 content/JSON——那是 D91 的位置換個門進來)
-- · 舊 entry_values 四欄的下場:record_id → record entry 自己的 identry_id → 指標終點(dst
-- slot_name → 指標謂詞(rel field entry)/template_id → 一條屬於關係(rel sys_belongs
-- · 那張表該死的理由:它是「關係」的第二套實作——同一件事兩個實作必然漂移(D92)
--
-- 資料遷移形狀(v7 §3):既有 value entries 一列都不動(id 穩定、向量索引不失效)、
-- 全部純 INSERT、零 UPDATE。以舊儲存格列 id 衍生關係列 id ⇒ INSERT OR IGNORE 天然冪等。
--
-- 冪等設計(三條套用路徑:官方 wrangler migrationscli deploy.ts 每次重跑/安裝器逐句重放):
-- · 除了三句 ADD COLUMN 之外,每一句都是語句級冪等(IF NOT EXISTSINSERT OR IGNORE
-- · SQLite 沒有「ADD COLUMN IF NOT EXISTS」——重跑時那三句會報 duplicate column
-- 套用端必須把「duplicate column」視為已套用(cli deploy.ts 的 applyD1MigrationTolerant、
-- 安裝器逐句 try/catch 本就容錯)。官方路徑走 wrangler migrations 追蹤表,只跑一次。
-- · 不走「重建整張 entries」的做法:那雖然能純語句冪等,但 deploy.ts 每次部署都重跑
-- migration ⇒ 每次部署全表複製一輪,直接吃掉 D1 每日列寫入額度。
-- · entry_values 拆表用 0006 的「墊表、搬、拆」手法:重跑時先墊一份空殼,搬 0 筆,再拆,無害。
--
-- 施工窗口注意:本檔跑完 entry_values 就不存在了,必須與讀寫端改版(kbdb/src 同一批)一起部署。
-- 舊 worker 碰新庫會炸 entry_values 不存在——這是刻意的(leo:「不做長期雙讀相容層,一次翻」)。
-- ============================================================
-- 1. 關係的物理載體:池上型別化指標欄 + 一對方向索引
-- ============================================================
ALTER TABLE entries ADD COLUMN src_id TEXT; -- kbdb-sql-ok: 牆內 migration 本體(v7 定案載體「池上型別化指標欄」),重跑由套用端容錯 duplicate column
ALTER TABLE entries ADD COLUMN rel_id TEXT; -- kbdb-sql-ok: 同上
ALTER TABLE entries ADD COLUMN dst_id TEXT; -- kbdb-sql-ok: 同上
-- 一對方向索引(v7 §2-1):src 端服務「r1 的所有欄位」,(dst,rel) 端服務
-- 「某張 sheet 的所有 record」(rel=屬於, dst=sheet)與「誰指到 e1」(dst=e1)。
-- partial index:內容 entry(指標欄全 NULL)不進索引,索引大小 ≈ 關係列數。
CREATE INDEX IF NOT EXISTS idx_entries_rel_src ON entries(src_id) WHERE src_id IS NOT NULL; -- kbdb-sql-ok: 牆內 migration 本體,普通欄位索引(0001 十條索引的同族)
CREATE INDEX IF NOT EXISTS idx_entries_rel_dst ON entries(dst_id, rel_id) WHERE dst_id IS NOT NULL; -- kbdb-sql-ok: 同上
-- ============================================================
-- 2. 啟動常數(v7 §2-3:一組、極小、只讀)
-- ============================================================
-- 保留根:屬於鏈的終點。某顆 entry ─屬於→ sys_root 它是一張 sheet。
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_root', 'root', 'system', NULL);
-- 「屬於」謂詞:record ─屬於→ sheet(歸屬關係的 rel)。
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_belongs', 'belongs', 'system', NULL);
-- 「欄位屬於表」謂詞:field ─field_of→ sheetsheet 的欄位名冊,與 record 歸屬分開,
-- 免得「某張 sheet 的所有 record」把欄位也撈進來)。
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_field_of', 'field_of', 'system', NULL);
-- ============================================================
-- 3. 墊表(0006 手法):本檔尾端會拆掉 entry_values,重跑時先墊空殼
-- 讓下面的搬遷語句永遠合法(搬 0 筆),最後再拆一次。
-- ============================================================
CREATE TABLE IF NOT EXISTS entry_values ( -- kbdb-sql-ok: 表退場施工步驟①保底存在(0006 同款),非資料存取違規
id TEXT PRIMARY KEY,
record_id TEXT NOT NULL,
template_id TEXT NOT NULL,
slot_name TEXT NOT NULL,
entry_id TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch()),
UNIQUE(record_id, slot_name)
);
-- ============================================================
-- 4. sheetfield 落池(templates 表遷移期雙軌:欄位定義的真相源暫仍在 templates 表,
-- sheet/field entry 是它在池中的身分——第二刀把 templates 表整個退役)
-- ============================================================
-- 每個 template 一顆 sheet entryid 沿用 template id,既有引用不失效)
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id, created_at, updated_at)
SELECT t.id, t.name, 'sheet', NULL, t.created_at, t.updated_at FROM templates t;
-- sheet ─屬於→ 保留根(「什麼讓一顆 entry 成為 sheet」的答案:這條關係)
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
SELECT 'relb_' || t.id, 'relation', t.id, 'sys_belongs', 'sys_root' FROM templates t;
-- 每個 slot 一顆 field entryid 決定性衍生:fld_<template>_<slot>,欄名=關係的謂詞)
INSERT OR IGNORE INTO entries (id, content, entry_type)
SELECT 'fld_' || t.id || '_' || j.value, j.value, 'field'
FROM templates t, json_each(t.slots_json) j;
-- field ─field_of→ sheet(欄位名冊)
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
SELECT 'relf_' || t.id || '_' || j.value, 'relation', 'fld_' || t.id || '_' || j.value, 'sys_field_of', t.id
FROM templates t, json_each(t.slots_json) j;
-- 保險網:entry_values 裡實際用過、但 slots_json 沒宣告的 slot(宣告與實作漂移的實證),
-- 一樣補出 field entry 與名冊,資料一筆都不能掉。
INSERT OR IGNORE INTO entries (id, content, entry_type)
SELECT DISTINCT 'fld_' || ev.template_id || '_' || ev.slot_name, ev.slot_name, 'field' FROM entry_values ev;
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
SELECT DISTINCT 'relf_' || ev.template_id || '_' || ev.slot_name, 'relation',
'fld_' || ev.template_id || '_' || ev.slot_name, 'sys_field_of', ev.template_id
FROM entry_values ev;
-- ============================================================
-- 5. record 有身分:每筆 record 一顆池中 entryid 原 record_id 字串)
-- 歸屬 owner 沿用「第一個非 NULL 的 slot entry owner」——與舊讀端的推導逐字同義。
-- INSERT OR IGNORE 的另一層意義:record_id 已經是池中既有 entrylibrary_map 的
-- block 即 record 慣例)時,那顆 entry 本人就是身分,不另建。
-- ============================================================
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, created_at, updated_at)
SELECT ev.record_id, 'record',
(SELECT e2.owner_id FROM entry_values ev2 JOIN entries e2 ON ev2.entry_id = e2.id
WHERE ev2.record_id = ev.record_id AND e2.owner_id IS NOT NULL LIMIT 1),
MIN(ev.created_at), MIN(ev.created_at)
FROM entry_values ev GROUP BY ev.record_id;
-- record ─屬於→ sheet(舊 template_id 欄的下場:變成一條關係)
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id, created_at)
SELECT 'relb_' || ev.record_id || '_' || ev.template_id, 'relation',
(SELECT e2.owner_id FROM entry_values ev2 JOIN entries e2 ON ev2.entry_id = e2.id
WHERE ev2.record_id = ev.record_id AND e2.owner_id IS NOT NULL LIMIT 1),
ev.record_id, 'sys_belongs', ev.template_id, MIN(ev.created_at)
FROM entry_values ev GROUP BY ev.record_id, ev.template_id;
-- ============================================================
-- 6. 每個儲存格 → 一條關係列(src=record、rel=欄位謂詞、dst=原 value entry
-- 關係列 id 以舊儲存格列 id 衍生(relv_<ev.id>)⇒ 重跑天然冪等。
-- ============================================================
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id, created_at)
SELECT 'relv_' || ev.id, 'relation', e.owner_id,
ev.record_id, 'fld_' || ev.template_id || '_' || ev.slot_name, ev.entry_id, ev.created_at
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id;
-- ============================================================
-- 7. 拆表(本提案的完成證明:關係的第二套實作不再有位置)
-- ============================================================
DROP TABLE IF EXISTS entry_values; -- kbdb-sql-ok: 表退場施工(0006 同款),v7 定案「兩套機制合成一套」的落地
+20
View File
@@ -73,10 +73,20 @@ export interface ListEntriesResult {
// 當 count 回傳,容易被誤讀成「總共只有這幾筆」。total 才是真總數,count 仍保留=本頁筆數。
}
// ── 機制列隔離(0007 樹狀 record 模型)───────────────────────────────
// 關係列/裸身分/sheet/field/啟動常數是模型的機械零件,不是使用者的「一筆知識」。
// caller 沒指定 entry_type 時預設排除,免得 owner-scoped 列表被 content=NULL 的關係列灌爆;
// caller 明白指定 entry_type(含指定成機制型別)→ 尊重他要的,不攔。
// 判「是不是關係列」認指標欄(src_id IS NOT NULL),不認 entry_type 標記——
// entry_type 在 v7 §7 白名單終局會整欄消失,這條謂詞到時只剩前半。
const NOT_MACHINERY_PREDICATE =
"(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))";
export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Promise<ListEntriesResult> {
const conds: string[] = [];
const params: unknown[] = [];
if (f.entry_type) { conds.push('entry_type = ?'); params.push(f.entry_type); }
else { conds.push(NOT_MACHINERY_PREDICATE); }
if (f.owner_id) { conds.push('owner_id = ?'); params.push(f.owner_id); }
if (f.parent_id) { conds.push('parent_id = ?'); params.push(f.parent_id); }
if (f.page_name) { conds.push('page_name = ?'); params.push(f.page_name); }
@@ -131,7 +141,14 @@ export async function updateEntry(db: D1Database, id: string, patch: UpdateEntry
}
export async function deleteEntry(db: D1Database, id: string): Promise<void> {
// 舊世界靠 FKentry_values.entry_id REFERENCES entries)擋「刪掉還被 record 指著的
// entry」;新模型(0007)關係列的 dst_id 沒有 FK → 這條不變量改由牆自己保,
// 否則會產出指向不存在 id 的孤兒關係列(孤兒巡檢見 relation-orphans.ts)。
const ref = await db.prepare('SELECT id FROM entries WHERE dst_id = ? LIMIT 1').bind(id).first<{ id: string }>();
if (ref) throw new Error(`entry ${id} is still referenced by record relation ${ref.id} — delete the record (or its slot) first`);
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
// 這顆 entry 自己發出的關係列(它是 record 身分時的格子與歸屬)失去意義,一併拆
await db.prepare('DELETE FROM entries WHERE src_id = ?').bind(id).run();
}
/**
@@ -583,6 +600,9 @@ export async function searchEntries(
const params: unknown[] = [...plan.scoreParams];
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
// 機制列隔離(0007):沒指定 entry_type 時,sheet/field 這類有 content 的機制節點
// 不進關鍵字搜尋(搜 "status" 不該撈回一顆欄位謂詞 entry);關係列 content=NULL 本就 0 分。
else { conds.push(NOT_MACHINERY_PREDICATE); }
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
+53 -39
View File
@@ -113,36 +113,45 @@ export async function ensureTripletLibrarySlot(db: D1Database, tripletTemplate:
// ---- 聚合 SQLM2 recompute ----
// recordentry_values 縱表)→ 一列一 triplet 的 pivot。MAX(CASE …) 是 SQLite 縱轉橫慣用法;
// owner filter 直接下在 pivot 前(record 的所有 slot entries 同 ownercreateRecord 寫入時同值)。
// 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 ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'subject' THEN e.content END) AS subject,
MAX(CASE WHEN ev.slot_name = 'object' THEN e.content END) AS object,
MAX(CASE WHEN ev.slot_name = 'predicate' THEN e.content END) AS predicate,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
MAX(CASE WHEN ev.slot_name = 'source_uri' THEN e.content END) AS source_uri
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${ownerFiltered ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id`;
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 查找共用)。
// library_map 自身 record 的 pivot(讀端+supersede 查找共用;同上 0007 形狀)。
function mapPivotSql(ownerFiltered: boolean): string {
return `SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
MAX(CASE WHEN ev.slot_name = 'narrative' THEN e.content END) AS narrative,
MAX(CASE WHEN ev.slot_name = 'top_entities' THEN e.content END) AS top_entities,
MAX(CASE WHEN ev.slot_name = 'relation_profile' THEN e.content END) AS relation_profile,
MAX(CASE WHEN ev.slot_name = 'bridges' THEN e.content END) AS bridges,
MAX(CASE WHEN ev.slot_name = 'triplet_count' THEN e.content END) AS triplet_count,
MAX(CASE WHEN ev.slot_name = 'commit_hash' THEN e.content END) AS commit_hash,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
MAX(ev.created_at) AS ts
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${ownerFiltered ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id`;
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[] {
@@ -360,18 +369,19 @@ async function liveTripletCountsByLibrary(
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(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
`SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n
FROM (
SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id
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
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
WHERE COALESCE(tr.status, 'active') = 'active'
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
GROUP BY COALESCE(NULLIF(tr.library, ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
@@ -398,6 +408,8 @@ async function liveEntryCountsByLibrary(db: D1Database, owner_id?: string): Prom
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')`,
)
@@ -429,10 +441,12 @@ async function knownLibraryNames(db: D1Database, owner_id?: string): Promise<Lib
const libParams: unknown[] = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
const libRows = await db
.prepare(
`SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id`,
`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 }>();
+247 -152
View File
@@ -1,5 +1,15 @@
// Template + Record CRUD. A "record" = multiple entries composed via a template's slots.
// Base, D1 only. (Ported clean from KBDB; no vectorize/triplet imports.)
// Template + Record CRUD — 樹狀 record 模型(v7 定稿,2026-08-15 confirm)。
//
// 模型(leo 定案):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」
// · record 池中一顆有身分的 entryrecord_id 就是它的 id
// · 一格 一條關係列(src=record、rel=field entry、dst=value entry)——池上型別化指標欄
// · 歸屬 一條關係列(src=record、rel=sys_belongs、dst=sheet
// · 欄位是關係、歸屬也是關係——同一種機制。entry_values 表已拆(0007),
// 它是「關係」的第二套實作(D92:同一件事兩個實作必然漂移)。
//
// 遷移期雙軌(第二刀收):templates 表仍是「欄位定義」的真相源(slots_jsondescription),
// sheetfield entry 是它們在池中的身分;createTemplate/updateTemplate 同步維護兩邊,
// 維護語句全部 INSERT OR IGNORE(決定性 id)⇒ 冪等、可自癒。
import type { Template } from '../types';
import { createEntry } from './entry-crud';
@@ -7,7 +17,46 @@ function uid(prefix: string): string {
return `${prefix}_${crypto.randomUUID()}`;
}
// ---- Templates ----
// ── 啟動常數(0007 seed;一組、極小、只讀)─────────────────────────────
export const SYS_ROOT = 'sys_root'; // 屬於鏈的終點:entry ─屬於→ sys_root 它是 sheet
export const SYS_BELONGS = 'sys_belongs'; // 歸屬謂詞:record ─屬於→ sheet
export const SYS_FIELD_OF = 'sys_field_of';// 欄位名冊謂詞:field ─field_of→ sheet
/** field entry 的決定性 id(0007 遷移與執行期寫入共用同一條衍生規則,兩邊永遠對得上)。 */
export function fieldEntryId(templateId: string, slot: string): string {
return `fld_${templateId}_${slot}`;
}
/** 啟動常數自癒(冪等;空庫或部分遷移的實例第一次寫入時補齊)。 */
async function ensureAnchors(db: D1Database): Promise<void> {
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES
('${SYS_ROOT}', 'root', 'system', NULL),
('${SYS_BELONGS}', 'belongs', 'system', NULL),
('${SYS_FIELD_OF}', 'field_of', 'system', NULL)`,
)
.run();
}
/** 這批 slot 的 field entry 名冊關係(field ─field_of→ sheet)自癒建立(冪等)。 */
async function ensureFieldEntries(db: D1Database, templateId: string, slots: string[]): Promise<void> {
for (const slot of slots) {
const fid = fieldEntryId(templateId, slot);
await db
.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'field')`)
.bind(fid, slot)
.run();
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_FIELD_OF}', ?)`,
)
.bind(`relf_${templateId}_${slot}`, fid, templateId)
.run();
}
}
// ---- Templates(遷移期雙軌:表=欄位定義真相源,池中 sheet/field entry=身分)----
export interface CreateTemplateInput {
name: string;
@@ -23,6 +72,19 @@ export async function createTemplate(db: D1Database, input: CreateTemplateInput)
.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`)
.bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null)
.run();
// 池中身分:sheet entryid 沿用 template id)+ sheet ─屬於→ 保留根 + 欄位名冊
await ensureAnchors(db);
await db
.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'sheet')`)
.bind(id, input.name)
.run();
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_BELONGS}', '${SYS_ROOT}')`,
)
.bind(`relb_${id}`, id)
.run();
await ensureFieldEntries(db, id, input.slots);
const row = await getTemplate(db, id);
if (!row) throw new Error('createTemplate: row not found after insert');
return row;
@@ -49,33 +111,20 @@ export async function updateTemplate(db: D1Database, id: string, patch: { descri
if (cols.length === 0) return getTemplate(db, id);
cols.push('updated_at = unixepoch()');
await db.prepare(`UPDATE templates SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run();
// 新增的 slot 要有 field entry(欄名=關係的謂詞),否則後續寫格子會指到不存在的謂詞
if (patch.slots !== undefined) await ensureFieldEntries(db, id, patch.slots);
return getTemplate(db, id);
}
// ---- Records (entry_values composed by template) ----
// ---- Records(關係列組成;entry_values 已拆)----
export interface CreateRecordInput {
template: string; // template id or name
values?: Record<string, string>; // slot_name -> content**新建**一筆 entry 當這個 slot 的值)
/**
* slot_name -> **** entry identries entry slot
* Arcrun#128
*
* 🔴
* leo 2026-08-15 blocks template/slots fields
* entry `entry_values`
* `migrations/0001_base.sql` `UNIQUE(record_id, slot_name)`
* **`entry_id` unique** entry record
* slot **** slot
* ** createEntry**
*
*
* ****
* App
* App template
* `InkStoneCo/system-dev/docs/4-guides/llm-wiki-schema.md`
*
* ** values ** id
* slot_name -> **** entry identries entry
* Arcrun#128
* value entry id entry****
*/
entry_ids?: Record<string, string>;
owner_id?: string | null;
@@ -86,21 +135,12 @@ export interface CreateRecordInput {
type ReferencedContent = Map<string, string | null>;
/**
* entry****
*
* FK
* 1. **id **FK D1 SQLITE_CONSTRAINT
* slot id
* 2. **** entry_id
* entry record `GET /records/:id`
* `.claude/rules/02-forbidden.md` §6.1
*
* 3. entry **** values entry
*
* INSERT
* base
*
* 90 id D1 100沿 searchByTemplate
* entry****
* 1. id FK
* 2. entry_id
* 3. entry
* INSERT
* 90 id D1 100沿
*/
async function loadReferencedEntries(
db: D1Database,
@@ -141,15 +181,35 @@ export interface RecordResult {
record_id: string;
template_id: string;
values: Record<string, string>;
/**
* record slot entries owner_idcreateRecord
* 2026-08-12 `GET /records/:id` ****
* id cypher portal /AI
* 404 null
*/
/** record 的歸屬。新模型直接存在 record 身分 entry 上(不再從 slot entries 推導)。 */
owner_id: string | null;
}
/** record 的歸屬關係(record ─屬於→ sheet,排除 sheet 自己的 ─屬於→ 保留根)。 */
async function recordBelongs(db: D1Database, recordId: string): Promise<{ dst_id: string } | null> {
const row = await db
.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id = '${SYS_BELONGS}' AND dst_id != '${SYS_ROOT}' LIMIT 1`)
.bind(recordId)
.first<{ dst_id: string }>();
return row ?? null;
}
async function insertCellRelation(
db: D1Database,
recordId: string,
templateId: string,
slot: string,
dstEntryId: string,
ownerId: string | null,
): Promise<void> {
await db
.prepare(
`INSERT INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, ?, ?)`,
)
.bind(uid('relv'), ownerId, recordId, fieldEntryId(templateId, slot), dstEntryId)
.run();
}
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
const tpl = await getTemplate(db, input.template);
if (!tpl) throw new Error(`template not found: ${input.template}`);
@@ -158,198 +218,233 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr
const values = input.values ?? {};
const entryIds = input.entry_ids ?? {};
const refSlots = Object.keys(entryIds);
const ownerId = input.owner_id ?? null;
// 同一個 slot 不准同時給字串又給 id:兩者的意思相反(複製一份 vs 指向既有),
// 猜哪一個都可能默默寫錯一份資料 ⇒ 當場報錯,不猜。
// 同一個 slot 不准同時給字串又給 id:兩者的意思相反(複製一份 vs 指向既有),不猜。
const both = refSlots.filter((s) => s in values);
if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(', ')}`);
// entry_ids 指到 template 沒有的 slot → 報錯(**不學 values 那條「靜默略過」**)。
// 理由:外鍵設了卻無聲消失是最難查的失敗——呼叫端會以為關聯建好了,
// 而 `template=` 查詢永遠撈不到它(查詢走 entry_values)。讓它當場講話。
// entry_ids 指到 template 沒有的 slot → 報錯(不學 values 那條「靜默略過」——
// 指標設了卻無聲消失是最難查的失敗)。
const unknown = refSlots.filter((s) => !slots.includes(s));
if (unknown.length > 0) throw new Error(`slot not in template: ${unknown.join(', ')}`);
// 全部檢查(存在/歸屬)先跑完再寫,失敗=一列都沒寫。
const referenced = await loadReferencedEntries(db, entryIds, input.owner_id ?? null);
const referenced = await loadReferencedEntries(db, entryIds, ownerId);
for (const slot of slots) {
// 參照既有 entry:只插一列關聯,**不碰 entries**(這就是外鍵)
// record 身分:池中一顆 entry。record_id 已是池中既有 entryblock 即 record 慣例,
// 如 library_map 的 map block)→ 那顆 entry 本人就是身分,不另建、不覆蓋
await db
.prepare(`INSERT OR IGNORE INTO entries (id, entry_type, owner_id) VALUES (?, 'record', ?)`)
.bind(recordId, ownerId)
.run();
// 歸屬=一條關係(舊 template_id 欄的下場)
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, '${SYS_BELONGS}', ?)`,
)
.bind(`relb_${recordId}_${tpl.id}`, ownerId, recordId, tpl.id)
.run();
// 欄位謂詞自癒(決定性 id,冪等)——只補這次真的要寫的 slot
const writtenSlots = slots.filter((s) => s in entryIds || s in values);
await ensureFieldEntries(db, tpl.id, writtenSlots);
for (const slot of writtenSlots) {
if (slot in entryIds) {
await db
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
.bind(uid('ev'), recordId, tpl.id, slot, entryIds[slot])
.run();
// 指向既有 entry:只插一條關係列,不碰內容(這就是指標)。
await insertCellRelation(db, recordId, tpl.id, slot, entryIds[slot], ownerId);
continue;
}
if (!(slot in values)) continue;
const entry = await createEntry(db, {
content: values[slot],
entry_type: 'value',
owner_id: input.owner_id ?? null,
owner_id: ownerId,
});
await db
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
.run();
await insertCellRelation(db, recordId, tpl.id, slot, entry.id, ownerId);
}
// 回傳值:舊路徑照舊原樣回 input.values(一字不變),參照來的 slot 補上那條既有 entry 的現有內容。
// 回傳值:舊路徑照舊原樣回 input.values,指標來的 slot 補上那條既有 entry 的現有內容。
const out: Record<string, string> = { ...values };
for (const [slot, entryId] of Object.entries(entryIds)) out[slot] = referenced.get(entryId) ?? '';
return { record_id: recordId, template_id: tpl.id, values: out, owner_id: input.owner_id ?? null };
return { record_id: recordId, template_id: tpl.id, values: out, owner_id: ownerId };
}
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
// "Deprecate by flipping a slot value" — base append-only is NOT broken: we change the
// underlying entries.content of the slot's entry, we do not alter table structure / add columns / delete rows.
// - slot already on the record → UPDATE the linked entries.content.
// - slot valid for the record's template but not yet present → create entry + entry_value (idempotent grow).
// - slot not in the template's slots_json → reject (records must stay template-shaped).
// Returns null if the record does not exist.
// Update an existing record's slot values(行為契約與 entry_values 時代一字不變):
// - slot 已有格子 → UPDATE 指到的 entries.content(**只有指標才連動**:所有指著同一顆的都看到新值)
// - slot 在 template 裡但還沒有格子 → 新建 entry + 一條關係列(grow
// - slot 不在 template → reject
// 回 null = record 不存在(沒有歸屬關係)。
export async function updateRecord(
db: D1Database,
recordId: string,
values: Record<string, string>,
): Promise<RecordResult | null> {
// Existing slot → entry_id + template_id for this record.
// JOIN entries 帶回 owner_idgrow 路徑建新 entry 時要沿用 record 既有 owner_id
//portal-auth design §2.2 附帶修復——原本漏帶 → 孤兒 entryowner_id=NULL),
// owner-scoped 查詢(searchByTemplate / searchEntries)看不到該 slot 值)。
const evRes = await db
const belongs = await recordBelongs(db, recordId);
if (!belongs) return null; // record does not exist
const templateId = belongs.dst_id;
// 既有格子:slot(謂詞 entry 的 content)→ 指到的 entry id(重複 slot 允許 → 全部收)
const cellRes = await db
.prepare(
`SELECT ev.slot_name AS slot_name, ev.entry_id AS entry_id, ev.template_id AS template_id, e.owner_id AS owner_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id = ?`,
`SELECT f.content AS slot_name, r.dst_id AS entry_id
FROM entries r JOIN entries f ON r.rel_id = f.id
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`,
)
.bind(recordId)
.all<{ slot_name: string; entry_id: string; template_id: string; owner_id: string | null }>();
const evRows = evRes.results ?? [];
if (evRows.length === 0) return null; // record does not exist
.all<{ slot_name: string; entry_id: string }>();
const cells = cellRes.results ?? [];
const slotToEntries = new Map<string, string[]>();
for (const c of cells) {
const list = slotToEntries.get(c.slot_name) ?? [];
list.push(c.entry_id);
slotToEntries.set(c.slot_name, list);
}
const templateId = evRows[0].template_id;
// record 的歸屬=其既有 slot entries 的 owner_idcreateRecord 寫入時同一值)。
const recordOwnerId = evRows.find((r) => r.owner_id != null)?.owner_id ?? null;
const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id]));
// record 的歸屬=身分 entry 的 ownergrow 建新 entry 時沿用,防孤兒 entry
const identity = await db.prepare('SELECT owner_id FROM entries WHERE id = ?').bind(recordId).first<{ owner_id: string | null }>();
const recordOwnerId = identity?.owner_id ?? null;
const tpl = await getTemplate(db, templateId);
const allowed: string[] = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()];
const allowed: string[] = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.keys()];
for (const [slot, content] of Object.entries(values)) {
if (!allowed.includes(slot)) {
throw new Error(`slot not in template: ${slot}`);
}
const entryId = slotToEntry.get(slot);
if (entryId) {
// flip the slot value: update the linked entry's content (table structure untouched)
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
const entryIds = slotToEntries.get(slot);
if (entryIds && entryIds.length > 0) {
// flip the slot value: update the linked entry's content(指標連動語意)
for (const entryId of entryIds) {
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
}
} else {
// valid template slot not yet on this record → grow it (create entry + link)
// owner_id 帶 record 既有歸屬(design §2.2 附帶修復,防孤兒 entry)
// valid template slot not yet on this record → growentry 關係列)
await ensureFieldEntries(db, templateId, [slot]);
const entry = await createEntry(db, { content, entry_type: 'value', owner_id: recordOwnerId });
await db
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
.bind(uid('ev'), recordId, templateId, slot, entry.id)
.run();
await insertCellRelation(db, recordId, templateId, slot, entry.id, recordOwnerId);
}
}
return getRecord(db, recordId);
}
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
const belongs = await recordBelongs(db, recordId);
if (!belongs) return null;
const res = await db
.prepare(
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id = ?`,
`SELECT f.content AS slot, v.content AS content
FROM entries r
JOIN entries f ON r.rel_id = f.id
JOIN entries v ON r.dst_id = v.id
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`,
)
.bind(recordId)
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
const rows = res.results ?? [];
if (rows.length === 0) return null;
.all<{ slot: string; content: string }>();
const values: Record<string, string> = {};
for (const r of rows) values[r.slot] = r.content;
// 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬)
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
for (const r of res.results ?? []) values[r.slot] = r.content;
const identity = await db.prepare('SELECT owner_id FROM entries WHERE id = ?').bind(recordId).first<{ owner_id: string | null }>();
return { record_id: recordId, template_id: belongs.dst_id, values, owner_id: identity?.owner_id ?? null };
}
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
const tpl = await getTemplate(db, template);
if (!tpl) return [];
// owner_id 過濾在 SQL 做:record 的歸屬存在底層 entries.owner_idcreateRecord 寫入時帶)。
// 給了 owner_id → JOIN entries 限定該 owner(租戶隔離,cypher proxy 強制注入);
// 沒給 → 不限(內部/全域查詢)。先前 `|| true` 是 stub,會洩漏跨租戶資料(2026-06-14 修)。
const cap = Math.min(limit, 500);
// record ids:歸屬關係(rel=屬於, dst=sheet)就是成員名單——(dst_id, rel_id) 索引直達。
// owner 過濾下在歸屬關係列的 owner_idcreateRecord 寫入時同值,0007 遷移同一推導)。
const res = owner_id
? await db
.prepare(
`SELECT DISTINCT ev.record_id as record_id FROM entry_values ev
JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ? AND e.owner_id = ?
ORDER BY ev.created_at DESC LIMIT ?`,
`SELECT src_id AS record_id FROM entries
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ? AND owner_id = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`,
)
.bind(tpl.id, owner_id, cap)
.all<{ record_id: string }>()
: await db
.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`)
.prepare(
`SELECT src_id AS record_id FROM entries
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`,
)
.bind(tpl.id, cap)
.all<{ record_id: string }>();
// 批次撈齊所有 record 的 slot 值(2026-07-18 修 N+1:原本逐筆 getRecord=每筆 1 次 D1
// 往返,100 筆 triplet ≈ 19 秒——graph/總圖/rag_chat 的「查詢 20-30 秒」病根就是這裡)。
// D1 綁定參數上限 100 → id 以 90 一組分批 IN 查詢;輸出保持原排序(created_at DESC)。
const ids = (res.results ?? []).map((r) => r.record_id);
if (ids.length === 0) return [];
// 批次撈齊格子與身分(N+1 教訓照舊:D1 綁定參數上限 100 → 90 一組)。
const byId = new Map<string, RecordResult>();
for (const id of ids) byId.set(id, { record_id: id, template_id: tpl.id, values: {}, owner_id: null });
for (let i = 0; i < ids.length; i += 90) {
const chunk = ids.slice(i, i + 90);
const placeholders = chunk.map(() => '?').join(',');
const evRes = await db
.prepare(
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id IN (${placeholders})`,
)
.bind(...chunk)
.all<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>();
for (const r of evRes.results ?? []) {
let rec = byId.get(r.record_id);
if (!rec) {
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
byId.set(r.record_id, rec);
}
rec.values[r.slot] = r.content;
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
const [cellRes, identRes] = await Promise.all([
db
.prepare(
`SELECT r.src_id AS record_id, f.content AS slot, v.content AS content
FROM entries r
JOIN entries f ON r.rel_id = f.id
JOIN entries v ON r.dst_id = v.id
WHERE r.src_id IN (${placeholders}) AND r.rel_id != '${SYS_BELONGS}'`,
)
.bind(...chunk)
.all<{ record_id: string; slot: string; content: string }>(),
db
.prepare(`SELECT id, owner_id FROM entries WHERE id IN (${placeholders})`)
.bind(...chunk)
.all<{ id: string; owner_id: string | null }>(),
]);
for (const r of cellRes.results ?? []) {
const rec = byId.get(r.record_id);
if (rec) rec.values[r.slot] = r.content;
}
for (const r of identRes.results ?? []) {
const rec = byId.get(r.id);
if (rec) rec.owner_id = r.owner_id;
}
}
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
}
/**
* record entry_valuesFK entries false record
* record entry entry
* false record
*
* 🔴 ** entry **Arcrun#128
* slot entry entry record
* record entry_id
* · **** slot
* · `entry_values.entry_id REFERENCES entries(id)` FK
* entry_values
*
* **** record
* `NOT EXISTS`
* 🔴 ** entry **Arcrun#128
* entry record
* 🔴 **block record **record_id blocklibrary_map
* entry entry_type 'record' block
*/
export async function deleteRecord(db: D1Database, recordId: string): Promise<boolean> {
const evRes = await db
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
const belongs = await recordBelongs(db, recordId);
if (!belongs) return false;
const cellRes = await db
.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id != '${SYS_BELONGS}'`)
.bind(recordId)
.all<{ entry_id: string }>();
const rows = evRes.results ?? [];
if (rows.length === 0) return false;
await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
for (const { entry_id } of rows) {
.all<{ dst_id: string }>();
const dsts = (cellRes.results ?? []).map((r) => r.dst_id);
// 這筆 record 發出的所有關係列(格子+歸屬)一次拆掉
await db.prepare(`DELETE FROM entries WHERE src_id = ?`).bind(recordId).run();
// 裸身分 entryentry_type='record')才刪;被別的關係指著就留(變回池中普通 entry)
await db
.prepare(
`DELETE FROM entries WHERE id = ?1 AND entry_type = 'record'
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)`,
)
.bind(recordId)
.run();
// 指到的內容 entry:沒有任何關係指著、自己也不發出關係、且不是機制節點 → 才刪
for (const dst of dsts) {
await db
.prepare('DELETE FROM entries WHERE id = ? AND NOT EXISTS (SELECT 1 FROM entry_values WHERE entry_id = ?)')
.bind(entry_id, entry_id)
.prepare(
`DELETE FROM entries WHERE id = ?1
AND entry_type NOT IN ('sheet', 'field', 'system')
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)
AND NOT EXISTS (SELECT 1 FROM entries WHERE src_id = ?1)
AND NOT EXISTS (SELECT 1 FROM entries WHERE rel_id = ?1)`,
)
.bind(dst)
.run();
}
return true;
+54
View File
@@ -0,0 +1,54 @@
// 關係列孤兒巡檢(0007 樹狀 record 模型的配套,v7 §5 明訂「要寫成明確的巡檢項,不能默認」)。
//
// 為什麼要有這支(不是順手加的):舊模型的孤兒偵測靠 entry_values 的外鍵形狀——
// 2026-06-24 那次 11 萬筆誤寫的清理就是拿 FK LEFT JOIN 找斷鏈
// system-dev/docs/5-records/2026-06-24-official-kbdb-cleanup-leo-misdelete.md)。
// 0007 拆掉 entry_values 後,關係列的 src/rel/dst 指標**沒有 FK**(同一張表指自己,
// SQLite 自參照 FK 會把插入順序綁死,且 D1 逐句執行無交易可 defer)⇒ 斷鏈不再被
// 資料庫擋下,改由本巡檢主動找:**孤兒=指標指向不存在 id 的關係列**。
// 掃法與舊 FK 形狀同款(LEFT JOIN 找斷鏈),v7 押的「形狀可承接」在這裡兌現。
//
// 什麼情況會產生孤兒(誠實列):
// 1. DELETE /entries/:id 的舊資料時代殘骸(新 deleteEntry 已擋 dst 被指著的刪除)
// 2. 遷移時 template 已被刪但 entry_values 還留著格子(0007 保險網補 field entry
// 但 dst=template 的名冊關係可能指到不存在的 sheet)
// 3. 未來任何繞過牆的直接寫入(本巡檢就是抓它們的網)
import type { D1Database } from '@cloudflare/workers-types';
export interface RelationOrphan {
relation_id: string;
role: 'src' | 'rel' | 'dst';
missing_id: string;
}
export interface RelationOrphanReport {
orphans: RelationOrphan[];
count: number; // 本次回報筆數(受 limit 截斷)
truncated: boolean; // true = 還有更多,加大 limit 或先清這批再掃
}
export async function scanRelationOrphans(db: D1Database, limit = 200): Promise<RelationOrphanReport> {
const cap = Math.min(Math.max(limit, 1), 1000);
const res = await db
.prepare(
`SELECT relation_id, role, missing_id FROM (
SELECT r.id AS relation_id, 'src' AS role, r.src_id AS missing_id
FROM entries r LEFT JOIN entries t ON t.id = r.src_id
WHERE r.src_id IS NOT NULL AND t.id IS NULL
UNION ALL
SELECT r.id, 'rel', r.rel_id
FROM entries r LEFT JOIN entries t ON t.id = r.rel_id
WHERE r.rel_id IS NOT NULL AND t.id IS NULL
UNION ALL
SELECT r.id, 'dst', r.dst_id
FROM entries r LEFT JOIN entries t ON t.id = r.dst_id
WHERE r.dst_id IS NOT NULL AND t.id IS NULL
) LIMIT ?`,
)
.bind(cap + 1)
.all<RelationOrphan>();
const rows = res.results ?? [];
const truncated = rows.length > cap;
const orphans = truncated ? rows.slice(0, cap) : rows;
return { orphans, count: orphans.length, truncated };
}
+10
View File
@@ -39,6 +39,16 @@ app.use('*', async (c, next) => {
app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' }));
app.get('/health', (c) => c.json({ ok: true }));
// 關係列孤兒巡檢(0007 配套;v7 §5「新模型的孤兒=指標指向不存在 id 的關係列」,
// 舊 entry_values FK 形狀的承接——2026-06-24 清理事故用的就是同款 LEFT JOIN 斷鏈掃描)。
// 唯讀,不自動清:清哪些要人裁(同 embed 孤兒清理的慣例,發現與處置分開)。
app.get('/maintenance/relation-orphans', async (c) => {
const { scanRelationOrphans } = await import('./actions/relation-orphans');
const limit = Number(c.req.query('limit') ?? '200');
const report = await scanRelationOrphans(c.env.DB, Number.isFinite(limit) ? limit : 200);
return c.json({ success: true, ...report });
});
app.route('/entries', entryRoutes);
app.route('/templates', templateRoutes);
app.route('/records', recordRoutes);
+10 -16
View File
@@ -36,27 +36,21 @@ recordRoutes.post('/', async (c) => {
// GET /records/triplet-stats?owner_id=... — 每個庫的三元組(關聯)數。
// t1422026-07-29):政府驗收——顯示每個庫整理出幾條知識關聯。
// 計法:依 triplet 型 record 的 'library' slot 值分組計數。無 library slot 的舊三元組歸 general。
// 使用子查詢先取 distinct triplet record IDs(針對 owner),再 LEFT JOIN library slot
// 避免 N+1(全部一次 SQL 完成,不逐筆 getRecord)。
// 0007 之後:record 成員名單=歸屬關係列(rel=sys_belongs, dst=triplet sheet
// library 格子=rel 為決定性欄位謂詞 idfld_<template>_library)的關係列——
// 一次 SQL 完成(無 N+1),owner 過濾下在歸屬關係列的 owner_id。
recordRoutes.get('/triplet-stats', async (c) => {
const owner = c.req.query('owner_id') || '';
// 子查詢:找到屬於這個 owner 的所有 triplet recordsLEFT JOIN library slot 取庫名
const rows = await c.env.DB.prepare(
`SELECT
COALESCE(NULLIF(lib_e.content, ''), 'general') AS library,
COALESCE(NULLIF(lib_v.content, ''), 'general') AS library,
COUNT(*) AS triplet_count
FROM (
SELECT DISTINCT ev.record_id
FROM entry_values ev
JOIN templates t ON ev.template_id = t.id
JOIN entries e ON ev.entry_id = e.id
WHERE t.name = 'triplet'
AND (?1 = '' OR e.owner_id = ?1)
) AS tr
LEFT JOIN entry_values lev
ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')
FROM entries b
JOIN templates t ON b.dst_id = t.id AND t.name = 'triplet'
LEFT JOIN entries lr ON lr.src_id = b.src_id AND lr.rel_id = ('fld_' || b.dst_id || '_library')
LEFT JOIN entries lib_v ON lib_v.id = lr.dst_id
WHERE b.rel_id = 'sys_belongs' AND (?1 = '' OR b.owner_id = ?1)
GROUP BY COALESCE(NULLIF(lib_v.content, ''), 'general')
ORDER BY library`,
)
.bind(owner)
+15 -1
View File
@@ -49,7 +49,16 @@ export type EntryType =
| 'execution_log'
| 'execution_log_usage'
| 'embed_backfill_usage'
| 'kbdb_maintenance_usage';
| 'kbdb_maintenance_usage'
// 樹狀 record 模型(0007v7 定稿 2026-08-15):機制節點與關係列。
// 誠實註記:entry_type 在 v7 §7 的白名單終局裡會整欄消失(型別只能由關係推導),
// 這裡先沿用它做遷移期的機制列標記——程式邏輯一律以指標欄(src_id IS NOT NULL)判斷
// 「是不是關係列」,不依賴這個標記。
| 'relation' // 一列關係:src ─rel→ dst(池上型別化指標欄)
| 'record' // record 的裸身分 entryblock 即 record 時身分是那顆 block,不是這個型別)
| 'sheet' // 一張表(template 在池中的身分,id 沿用 template id
| 'field' // 一個欄位(欄名=關係的謂詞)
| 'system'; // 啟動常數(sys_root / sys_belongs / sys_field_of
export interface Entry {
id: string;
@@ -65,6 +74,11 @@ export interface Entry {
is_embedded: number;
confidence: number | null;
metadata_json: string | null;
// 關係的物理載體(0007):池上型別化指標欄。內容 entry 三欄全 NULL
// 關係列三欄全非 NULL(src ─rel→ dst)。紅線:指標永不塞回 content/JSOND91)。
src_id: string | null;
rel_id: string | null;
dst_id: string | null;
created_at: number;
updated_at: number;
}
@@ -29,6 +29,7 @@ function makeSqliteD1(): D1Database {
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔,比照 execution-log.test.ts
raw.exec(readFileSync(new URL('../migrations/0002_credentials.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0005_credential_template.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
@@ -89,8 +90,9 @@ describe('credential-legacy-migration — 反向驗證:重現 2026-08-07 youli
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) // kbdb-sql-ok: 測試查詢
.all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
// entries/templates/entry_values 三張核心表 + credentials(舊表,尚未清理)——沒有第五張表。
expect(names).toEqual(['credentials', 'entries', 'entry_values', 'templates']);
// entries/templates 兩張核心表(entry_values 已由 0007 拆掉——關係的第二套實作,D92)
// + credentials(舊表,尚未清理)——沒有第四張表。
expect(names).toEqual(['credentials', 'entries', 'templates']);
});
});
+1
View File
@@ -31,6 +31,7 @@ const CURRENT_MODEL = '@cf/baai/bge-m3'; // embed.ts DEFAULT_EMBED_MODEL(未 e
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 原檔
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
+1 -1
View File
@@ -12,7 +12,7 @@ function mkEntry(id: string, content: string, ownerId = 'leo', is_embedded = 1):
return {
id, content, entry_type: 'block', owner_id: ownerId, parent_id: null, page_name: null,
refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded,
confidence: null, metadata_json: JSON.stringify({ embed: true }), created_at: 1, updated_at: 1,
confidence: null, metadata_json: JSON.stringify({ embed: true }), src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
+7 -2
View File
@@ -36,6 +36,7 @@ function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
@@ -70,7 +71,11 @@ describe('execution-log — schema 零異動(證明沒建表)', () => {
expect(sql).toContain('INSERT OR IGNORE INTO templates');
});
it('template 存在(tpl-execution-log),entries/templates/entry_values 三表結構不變', async () => {
// 0007(樹狀 record 模型,v7 定稿 2026-08-15 confirm)後的正解表清單:entry_values 已拆
// ——它是「關係」的第二套實作(D92),關係列改住 entries 的指標欄。這條斷言在改版前
// 釘的是「三張核心表」,規格層變更(pending-changes.md「record 要有身分」)把答案改掉,
// 不是實作去配合測試。
it('template 存在(tpl-execution-log),核心表只剩 entries/templatesentry_values 已拆,0007', async () => {
const db = makeSqliteD1();
const tpl = await db.prepare('SELECT * FROM templates WHERE name = ?').bind('execution_log').first<{ id: string }>();
expect(tpl?.id).toBe('tpl-execution-log');
@@ -79,7 +84,7 @@ describe('execution-log — schema 零異動(證明沒建表)', () => {
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
).all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
expect(names).toEqual(['entries', 'entry_values', 'templates']);
expect(names).toEqual(['entries', 'templates']);
});
});
+1
View File
@@ -23,6 +23,7 @@ import type { Bindings, Entry, EntryType } 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 原檔
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
+1 -1
View File
@@ -33,7 +33,7 @@ function mkEntry(id: string, metadata_json: string | null): Entry {
return {
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 0, confidence: null, metadata_json, created_at: 1, updated_at: 1,
is_embedded: 0, confidence: null, metadata_json, src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
+1
View File
@@ -25,6 +25,7 @@ function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
+8 -3
View File
@@ -23,6 +23,7 @@ type SlotIds = Record<string, string>;
function makeSqliteD1(): { db: D1Database; raw: DatabaseSync } {
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 原檔
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
raw.exec('PRAGMA foreign_keys = ON'); // kbdb-sql-ok:測試治具——本地也打開 FK,才測得到「刪掉別人還指著的 entry」會怎樣
function stmt(sql: string, params: unknown[]) {
const s = {
@@ -36,12 +37,16 @@ function makeSqliteD1(): { db: D1Database; raw: DatabaseSync } {
return { db: { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database, raw };
}
// 0007 樹狀 record 模型之後的記帳單位:
// 「水池列數」=**內容列**(不含關係列與機制節點)——#128 的驗收標準是「外鍵不複製內容」,
// 關係列本來就是指標的載體,多一條指標不是多一份內容。
// 「關聯列」=格子關係列(rel 不是 sys_belongs / sys_field_of 的關係列),對應舊 entry_values。
const countEntries = (raw: DatabaseSync): number =>
(raw.prepare('SELECT COUNT(*) AS n FROM entries').get() as { n: number }).n; // kbdb-sql-ok:測試治具
(raw.prepare("SELECT COUNT(*) AS n FROM entries WHERE src_id IS NULL AND entry_type NOT IN ('record','sheet','field','system')").get() as { n: number }).n; // kbdb-sql-ok:測試治具
const countEntryValues = (raw: DatabaseSync): number =>
(raw.prepare('SELECT COUNT(*) AS n FROM entry_values').get() as { n: number }).n; // kbdb-sql-ok:測試治具
(raw.prepare("SELECT COUNT(*) AS n FROM entries WHERE src_id IS NOT NULL AND rel_id NOT IN ('sys_belongs','sys_field_of')").get() as { n: number }).n; // kbdb-sql-ok:測試治具
const entryIdOfSlot = (raw: DatabaseSync, recordId: string, slot: string): string | undefined =>
(raw.prepare('SELECT entry_id FROM entry_values WHERE record_id = ? AND slot_name = ?').get(recordId, slot) as // kbdb-sql-ok:測試治具
(raw.prepare('SELECT r.dst_id AS entry_id FROM entries r JOIN entries f ON r.rel_id = f.id WHERE r.src_id = ? AND f.content = ?').get(recordId, slot) as // kbdb-sql-ok:測試治具
| { entry_id: string }
| undefined)?.entry_id;
+20 -9
View File
@@ -10,12 +10,15 @@ import { updateRecord } from '../src/actions/record-crud';
interface Captured { sql: string; params: unknown[] }
/**
* fake D1
* - entry_values JOIN entries slot rows owner_id
* fake D10007 record SQL
* - SELECT dst_id rel_id = 'sys_belongs' record sheet
* - JOIN entries f ON r.rel_id = f.id v JOIN slot rows
* - owner SELECT owner_id FROM entries WHERE id record
* - templates templateslots_json + grow slot
* - INSERT INTO entries
* - INSERT INTO entries ensureFieldEntries
* INSERT OR IGNORE
* - SELECT * FROM entries WHERE id entrycreateEntry insert
* - UPDATE / INSERT entry_values / getRecord SELECT
* - UPDATE / INSERT / getRecord SELECT
*/
function makeRoutedDB(recordOwnerId: string | null, captured: Captured[]) {
const prepare = (sql: string) => {
@@ -24,30 +27,38 @@ function makeRoutedDB(recordOwnerId: string | null, captured: Captured[]) {
const stmt = {
bind(...args: unknown[]) { rec.params = args; return stmt; },
async all<T>() {
if (sql.includes('FROM entry_values ev JOIN entries e')) {
// getRecord 的格子查詢(有 v JOIN)→ 空殼;updateRecord 的格子查詢(無 v JOIN)→ 既有 slot
if (sql.includes('JOIN entries v ON r.dst_id = v.id')) {
return { results: [] as T[] };
}
if (sql.includes('JOIN entries f ON r.rel_id = f.id')) {
return {
results: [
{ slot_name: 'email', entry_id: 'e_existing', template_id: 'tpl_pu', owner_id: recordOwnerId },
{ slot_name: 'email', entry_id: 'e_existing' },
] as unknown as T[],
};
}
if (sql.includes('FROM entry_values ev JOIN entries e ON ev.entry_id = e.id')) {
return { results: [] as T[] };
}
return { results: [] as T[] };
},
async first<T>() {
if (sql.includes("rel_id = 'sys_belongs'") && sql.includes('SELECT dst_id')) {
return { dst_id: 'tpl_pu' } as unknown as T;
}
if (sql.includes('FROM templates')) {
return {
id: 'tpl_pu', name: 'portal_user', description: null,
slots_json: JSON.stringify(['email', 'status']), created_by: 'system',
} as unknown as T;
}
if (sql.startsWith('SELECT owner_id FROM entries WHERE id')) {
return { owner_id: recordOwnerId } as unknown as T;
}
if (sql.startsWith('SELECT * FROM entries WHERE id')) {
return {
id: 'e_new', content: 'active', entry_type: 'value', owner_id: recordOwnerId,
parent_id: null, page_name: null, refs_json: '[]', tags_json: '[]', task_status: null,
content_hash: null, is_embedded: 0, confidence: null, metadata_json: null,
src_id: null, rel_id: null, dst_id: null,
created_at: 1, updated_at: 1,
} as unknown as T;
}
+1 -1
View File
@@ -26,7 +26,7 @@ function mkEntry(id: string, metadata_json: string | null): Entry {
return {
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 0, confidence: null, metadata_json, created_at: 1, updated_at: 1,
is_embedded: 0, confidence: null, metadata_json, src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
+4 -1
View File
@@ -28,6 +28,7 @@ import {
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:測試治具套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
return {
bind(...args: unknown[]) { return stmt(sql, args); },
@@ -45,8 +46,10 @@ const legacyPattern = (q: string) => `%${q}%`;
/** 對真 SQLite 跑一次 `content LIKE ?`,回命中的 content(要不要帶 ESCAPE 可選)。 */
async function likeHits(db: D1Database, pattern: string, escape: boolean): Promise<string[]> {
const pred = escape ? "content LIKE ? ESCAPE '\\'" : 'content LIKE ?';
// 0007 之後池裡多了機制節點(sys_* 啟動常數等有 content 的列)——比照 searchEntries 的
// 機制列隔離謂詞排除,讓「全庫」仍然指使用者的語料庫,前後對照的語意不變。
const res = await db
.prepare(`SELECT content FROM entries WHERE ${pred} ORDER BY id`) // kbdb-sql-ok:測試治具讀回斷言用
.prepare(`SELECT content FROM entries WHERE src_id IS NULL AND entry_type NOT IN ('record','sheet','field','system') AND ${pred} ORDER BY id`) // kbdb-sql-ok:測試治具讀回斷言用
.bind(pattern)
.all<{ content: string }>();
return (res.results ?? []).map((x) => x.content);
+1 -1
View File
@@ -21,7 +21,7 @@ function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 1, confidence: null,
metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated', embed: true }) : JSON.stringify({ embed: true }),
created_at: 1, updated_at: 1,
src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
@@ -20,7 +20,7 @@ function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry {
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 1, confidence: null,
metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated' }) : JSON.stringify({ embed: true }),
created_at: 1, updated_at: 1,
src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
+1 -1
View File
@@ -39,7 +39,7 @@ function mkEntry(id: string): Entry {
return {
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
is_embedded: 0, confidence: null, metadata_json: null, created_at: 1, updated_at: 1,
is_embedded: 0, confidence: null, metadata_json: null, src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
};
}
+154
View File
@@ -0,0 +1,154 @@
// 0007 樹狀 record 模型遷移驗收(v7 定稿,leo 2026-08-15 confirm)。
//
// 驗收條件(派工單指定,逐條對應):
// 1. 既有資料一筆都不能掉:entry_values 每列 → 一條格子關係列;每筆 record → 身分 entry
// 2. 冪等:同一份遷移語句跑兩次結果相同——用最嚴苛的那條套用路徑模擬
// (安裝器逐句重放:剝註解、依分號切句、逐句執行、容錯 duplicate column
// 3. value entries 一列都不動(id 穩定 ⇒ 向量索引不失效)
// 4. 孤兒偵測有答案:新模型的孤兒=指標指向不存在 id 的關係列(LEFT JOIN 斷鏈掃描)
//
// 測試策略比照 execution-log.test.ts:真 SQLitenode:sqlite)套 migrations 原檔。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { scanRelationOrphans } from '../src/actions/relation-orphans';
import { getRecord, searchByTemplate } from '../src/actions/record-crud';
// ── 安裝器逐句重放模式(products/arcrun-rag installer 的實際手法)──────────────
function splitStatements(sql: string): string[] {
return sql
.split('\n')
.map((l) => l.replace(/--.*$/, ''))
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
function applyTolerant(raw: DatabaseSync, file: string): string[] {
const sql = readFileSync(new URL(`../migrations/${file}`, import.meta.url), 'utf8');
const errors: string[] = [];
for (const stmt of splitStatements(sql)) {
try {
raw.exec(stmt); // kbdb-sql-ok:測試治具逐句重放 migration 原檔(模擬安裝器路徑)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用
errors.push(`${stmt.slice(0, 50)}… → ${msg}`);
}
}
return errors;
}
// ── 舊世界資料庫(0001 schema entry_values 真資料)────────────────────────
function legacyDB(): DatabaseSync {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 migration 原檔
raw.exec(`INSERT OR IGNORE INTO templates (id,name,description,slots_json,created_by) VALUES ('tpl-triplet','triplet','t','["subject","predicate","object","library"]','system')`); // kbdb-sql-ok:測試治具 seed
const insE = raw.prepare(`INSERT INTO entries (id,content,entry_type,owner_id) VALUES (?,?,?,?)`); // kbdb-sql-ok:測試治具 seed
const insEV = raw.prepare(`INSERT INTO entry_values (id,record_id,template_id,slot_name,entry_id) VALUES (?,?,?,?,?)`); // kbdb-sql-ok:測試治具 seed(舊表在 0007 前存在)
for (let r = 0; r < 40; r++) {
for (const s of ['subject', 'predicate', 'object', 'library']) {
insE.run(`e_${r}_${s}`, `${s}-${r}`, 'value', 'leo');
insEV.run(`ev_${r}_${s}`, `rec_${r}`, 'tpl-triplet', s, `e_${r}_${s}`);
}
}
// 一筆「block 即 record」(library_map 慣例):record_id 是既有 block entry 的 id
insE.run('blk_map', '地圖 block', 'block', 'leo');
insE.run('e_map_lib', 'general', 'value', 'leo');
insEV.run('ev_map_lib', 'blk_map', 'tpl-triplet', 'library', 'e_map_lib');
return raw;
}
const count = (raw: DatabaseSync, sql: string): number => (raw.prepare(sql).get() as { n: number }).n; // kbdb-sql-ok:測試治具讀回斷言
function snapshot(raw: DatabaseSync) {
return {
total: count(raw, 'SELECT COUNT(*) AS n FROM entries'),
cells: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE src_id IS NOT NULL AND rel_id NOT IN ('sys_belongs','sys_field_of')"),
belongs: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE rel_id = 'sys_belongs'"),
identities: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'record'"),
values: count(raw, "SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'value'"),
evTable: count(raw, "SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='entry_values'"),
};
}
// node:sqlite → D1 最小 adapter(同 execution-log.test.ts 手法)
function asD1(raw: DatabaseSync): D1Database {
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 never[])) as T[] }; }, // kbdb-sql-ok:測試治具
async first<T>() { return (raw.prepare(sql).get(...(params as never[])) ?? null) as T | null; }, // kbdb-sql-ok:測試治具
async run() { const r = raw.prepare(sql).run(...(params as never[])); return { success: true, meta: { changes: r.changes } }; }, // kbdb-sql-ok:測試治具
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
describe('0007 — 資料一筆都不能掉(entry_values → 關係列)', () => {
it('每格一條關係列、每 record 一顆身分、value entries 一列不動、舊表拆掉', () => {
const raw = legacyDB();
const evBefore = count(raw, 'SELECT COUNT(*) AS n FROM entry_values');
const valuesBefore = count(raw, "SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'value'");
const valueIdsBefore = raw.prepare("SELECT id FROM entries WHERE entry_type='value' ORDER BY id").all(); // kbdb-sql-ok:測試治具
const errors = applyTolerant(raw, '0007_tree_record_model.sql');
expect(errors).toEqual([]);
const s = snapshot(raw);
expect(s.cells).toBe(evBefore); // 每格一條關係列,一列不漏
expect(s.values).toBe(valuesBefore); // value entries 沒被動
expect(s.evTable).toBe(0); // entry_values 不存在=遷移完成的證明(v7 §7)
// 41 筆 record40 顆裸身分(entry_type='record')+ 1 顆 block 即 record(不另建身分)
expect(s.identities).toBe(40);
expect(count(raw, "SELECT COUNT(*) AS n FROM entries WHERE id = 'blk_map' AND entry_type = 'block'")).toBe(1);
// 歸屬:41 筆 record 2 張 sheettpl-triplet 與 0001 自帶 seed 的 tpl-recipe-stat,各一條 ─屬於→ sys_root
expect(s.belongs).toBe(43);
// id 穩定(向量索引不失效):value entry 的 id 集合前後逐字相同
const valueIdsAfter = raw.prepare("SELECT id FROM entries WHERE entry_type='value' ORDER BY id").all(); // kbdb-sql-ok:測試治具
expect(valueIdsAfter).toEqual(valueIdsBefore);
});
it('遷移後讀端讀得回同樣的資料(getRecordsearchByTemplate 走關係列)', async () => {
const raw = legacyDB();
applyTolerant(raw, '0007_tree_record_model.sql');
const db = asD1(raw);
const rec = await getRecord(db, 'rec_7');
expect(rec).not.toBeNull();
expect(rec!.values).toEqual({ subject: 'subject-7', predicate: 'predicate-7', object: 'object-7', library: 'library-7' });
expect(rec!.template_id).toBe('tpl-triplet');
expect(rec!.owner_id).toBe('leo');
const all = await searchByTemplate(db, 'triplet', 'leo', 500);
expect(all.length).toBe(41);
});
});
describe('0007 — 冪等(同一份語句跑兩次結果相同;三條套用路徑最嚴苛的一條)', () => {
it('第二次逐句重放:零錯誤、快照逐字相等', () => {
const raw = legacyDB();
const e1 = applyTolerant(raw, '0007_tree_record_model.sql');
const s1 = snapshot(raw);
const e2 = applyTolerant(raw, '0007_tree_record_model.sql');
const s2 = snapshot(raw);
expect(e1).toEqual([]);
expect(e2).toEqual([]);
expect(s2).toEqual(s1);
});
});
describe('0007 配套 — 孤兒偵測(v7 §5:新模型的孤兒要有答案,不能默認)', () => {
it('乾淨遷移後零孤兒;人為斷鏈後掃得到、指得出斷在哪一端', async () => {
const raw = legacyDB();
applyTolerant(raw, '0007_tree_record_model.sql');
const db = asD1(raw);
expect((await scanRelationOrphans(db)).count).toBe(0);
// 人為製造斷鏈(模擬繞牆直刪——正是 2026-06-24 事故那類殘局)
raw.exec("DELETE FROM entries WHERE id = 'e_3_object'"); // kbdb-sql-ok:測試治具刻意製造孤兒
const report = await scanRelationOrphans(db);
expect(report.count).toBe(1);
expect(report.orphans[0]).toEqual({ relation_id: 'relv_ev_3_object', role: 'dst', missing_id: 'e_3_object' });
});
});
@@ -34,6 +34,7 @@ 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: 同上
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },