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
+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 的 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_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;
}