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:
@@ -721,6 +721,22 @@ export async function downloadAndDeploy(
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql(${execLogMigPath})`);
|
||||
}
|
||||
|
||||
// 3.8 樹狀 record 模型(0007,v7 定稿 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 column(0007 專用)。
|
||||
*
|
||||
* 為什麼不能走 applyD1Migration 整檔送:/query 端點任何一句失敗整批中止——
|
||||
* 0007 的三句 ADD COLUMN 在重跑時必然報 duplicate column(SQLite 沒有欄位級
|
||||
* 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 + accountId;query 端點接受多語句檔,一次送整份 0001_base.sql。
|
||||
|
||||
@@ -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 自己的 id/entry_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 migrations/cli deploy.ts 每次重跑/安裝器逐句重放):
|
||||
-- · 除了三句 ADD COLUMN 之外,每一句都是語句級冪等(IF NOT EXISTS/INSERT 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→ sheet(sheet 的欄位名冊,與 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. sheet/field 落池(templates 表遷移期雙軌:欄位定義的真相源暫仍在 templates 表,
|
||||
-- sheet/field entry 是它在池中的身分——第二刀把 templates 表整個退役)
|
||||
-- ============================================================
|
||||
|
||||
-- 每個 template 一顆 sheet entry(id 沿用 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 entry(id 決定性衍生: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 一顆池中 entry(id = 原 record_id 字串)
|
||||
-- 歸屬 owner 沿用「第一個非 NULL 的 slot entry owner」——與舊讀端的推導逐字同義。
|
||||
-- INSERT OR IGNORE 的另一層意義:record_id 已經是池中既有 entry(library_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 定案「兩套機制合成一套」的落地
|
||||
@@ -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> {
|
||||
// 舊世界靠 FK(entry_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); }
|
||||
|
||||
@@ -113,36 +113,45 @@ export async function ensureTripletLibrarySlot(db: D1Database, tripletTemplate:
|
||||
|
||||
// ---- 聚合 SQL(M2 recompute) ----
|
||||
|
||||
// record(entry_values 縱表)→ 一列一 triplet 的 pivot。MAX(CASE …) 是 SQLite 縱轉橫慣用法;
|
||||
// owner filter 直接下在 pivot 前(record 的所有 slot entries 同 owner,createRecord 寫入時同值)。
|
||||
// record → 一列一 triplet 的 pivot(0007 樹狀 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_id(createRecord 寫入時同值,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
@@ -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 = 池中一顆有身分的 entry(record_id 就是它的 id)
|
||||
// · 一格 = 一條關係列(src=record、rel=field entry、dst=value entry)——池上型別化指標欄
|
||||
// · 歸屬 = 一條關係列(src=record、rel=sys_belongs、dst=sheet)
|
||||
// · 欄位是關係、歸屬也是關係——同一種機制。entry_values 表已拆(0007),
|
||||
// 它是「關係」的第二套實作(D92:同一件事兩個實作必然漂移)。
|
||||
//
|
||||
// 遷移期雙軌(第二刀收):templates 表仍是「欄位定義」的真相源(slots_json/description),
|
||||
// sheet/field 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 entry(id 沿用 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 的 id:把水池(entries)裡那條既有 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 的 id:把水池(entries)裡那條既有 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_id,createRecord 寫入時同一值)。
|
||||
* 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 已是池中既有 entry(block 即 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_id:grow 路徑建新 entry 時要沿用 record 既有 owner_id
|
||||
//(portal-auth design §2.2 附帶修復——原本漏帶 → 孤兒 entry(owner_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_id(createRecord 寫入時同一值)。
|
||||
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 的 owner(grow 建新 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 → grow(entry + 關係列)
|
||||
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_id(createRecord 寫入時帶)。
|
||||
// 給了 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_id(createRecord 寫入時同值,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_values(FK),再刪底層 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 是既有 block(library_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();
|
||||
// 裸身分 entry(entry_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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -36,27 +36,21 @@ recordRoutes.post('/', async (c) => {
|
||||
// GET /records/triplet-stats?owner_id=... — 每個庫的三元組(關聯)數。
|
||||
// t142(2026-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 為決定性欄位謂詞 id(fld_<template>_library)的關係列——
|
||||
// 一次 SQL 完成(無 N+1),owner 過濾下在歸屬關係列的 owner_id。
|
||||
recordRoutes.get('/triplet-stats', async (c) => {
|
||||
const owner = c.req.query('owner_id') || '';
|
||||
// 子查詢:找到屬於這個 owner 的所有 triplet records;LEFT 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
@@ -49,7 +49,16 @@ export type EntryType =
|
||||
| 'execution_log'
|
||||
| 'execution_log_usage'
|
||||
| 'embed_backfill_usage'
|
||||
| 'kbdb_maintenance_usage';
|
||||
| 'kbdb_maintenance_usage'
|
||||
// 樹狀 record 模型(0007,v7 定稿 2026-08-15):機制節點與關係列。
|
||||
// 誠實註記:entry_type 在 v7 §7 的白名單終局裡會整欄消失(型別只能由關係推導),
|
||||
// 這裡先沿用它做遷移期的機制列標記——程式邏輯一律以指標欄(src_id IS NOT NULL)判斷
|
||||
// 「是不是關係列」,不依賴這個標記。
|
||||
| 'relation' // 一列關係:src ─rel→ dst(池上型別化指標欄)
|
||||
| 'record' // record 的裸身分 entry(block 即 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/JSON(D91)。
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/templates(entry_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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 D1(0007 樹狀 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 查詢 → 回 template(slots_json 含既有 + 可 grow 的 slot)
|
||||
* - INSERT INTO entries → 捕捉參數(本測試的斷言目標)
|
||||
* - INSERT INTO entries → 捕捉參數(本測試的斷言目標;ensureFieldEntries 走
|
||||
* INSERT OR IGNORE,字面不同,不會污染這個斷言)
|
||||
* - SELECT * FROM entries WHERE id → 回假 entry(createEntry 的 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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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:真 SQLite(node: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 筆 record:40 顆裸身分(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 張 sheet(tpl-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('遷移後讀端讀得回同樣的資料(getRecord/searchByTemplate 走關係列)', 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); },
|
||||
|
||||
Reference in New Issue
Block a user