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

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

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

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

452 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
function uid(prefix: string): string {
return `${prefix}_${crypto.randomUUID()}`;
}
// ── 啟動常數(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;
description?: string | null;
slots: string[];
created_by?: string | null;
id?: string;
}
export async function createTemplate(db: D1Database, input: CreateTemplateInput): Promise<Template> {
const id = input.id ?? uid('tpl');
await db
.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;
}
export async function getTemplate(db: D1Database, idOrName: string): Promise<Template | null> {
const row = await db
.prepare('SELECT * FROM templates WHERE id = ? OR name = ? LIMIT 1')
.bind(idOrName, idOrName)
.first<Template>();
return row ?? null;
}
export async function listTemplates(db: D1Database): Promise<Template[]> {
const res = await db.prepare('SELECT * FROM templates ORDER BY created_at DESC').all<Template>();
return res.results ?? [];
}
export async function updateTemplate(db: D1Database, id: string, patch: { description?: string | null; slots?: string[] }): Promise<Template | null> {
const cols: string[] = [];
const params: unknown[] = [];
if (patch.description !== undefined) { cols.push('description = ?'); params.push(patch.description); }
if (patch.slots !== undefined) { cols.push('slots_json = ?'); params.push(JSON.stringify(patch.slots)); }
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 已拆)----
export interface CreateRecordInput {
template: string; // template id or name
values?: Record<string, string>; // slot_name -> content**新建**一筆 entry 當這個 slot 的值)
/**
* slot_name -> **既有** entry 的 id:把水池(entries)裡那條既有 entry 直接掛上——
* 在新模型裡這就是「一條指標」本人(Arcrun#128 想要的外鍵,現在是唯一機制的原生形狀)。
* 給字串 → 照舊新建 value entry 再指過去;給 id → 直接指既有 entry。**只有指標才連動**。
*/
entry_ids?: Record<string, string>;
owner_id?: string | null;
record_id?: string;
}
/** 被參照 entry 的 id -> 它現在的 content(回傳值要帶真內容,不是空殼)。 */
type ReferencedContent = Map<string, string | null>;
/**
* 讀出被參照的既有 entry,並在**寫入任何一列之前**把該擋的擋掉:
* 1. id 不存在要給看得懂的錯(新模型沒有 FK,這層檢查就是牆自己的不變量)
* 2. 跨租戶必須擋(呼叫端可指定 entry_id,不檢查歸屬=繞過租戶邊界的門)
* 3. 回傳值帶被參照 entry 的現有內容
* 全部檢查在第一筆 INSERT 之前跑完 ⇒ 失敗就是「一列都沒寫」。
* 批次以 90 個 id 一組:D1 綁定參數上限 100,沿用既有慣例。
*/
async function loadReferencedEntries(
db: D1Database,
entryIds: Record<string, string>,
recordOwnerId: string | null,
): Promise<ReferencedContent> {
const ids = [...new Set(Object.values(entryIds))];
if (ids.length === 0) return new Map();
const rows: { id: string; content: string | null; owner_id: string | null }[] = [];
for (let i = 0; i < ids.length; i += 90) {
const chunk = ids.slice(i, i + 90);
const res = await db
.prepare(`SELECT id, content, owner_id FROM entries WHERE id IN (${chunk.map(() => '?').join(',')})`)
.bind(...chunk)
.all<{ id: string; content: string | null; owner_id: string | null }>();
rows.push(...(res.results ?? []));
}
const found = new Map(rows.map((r) => [r.id, r]));
const missing = ids.filter((id) => !found.has(id));
if (missing.length > 0) throw new Error(`entry not found: ${missing.join(', ')}`);
// 歸屬不同 → 擋。owner_id 為 null 的 entry 視為無主/共用(既有資料多半如此),放行。
if (recordOwnerId != null) {
const foreign = rows.filter((r) => r.owner_id != null && r.owner_id !== recordOwnerId);
if (foreign.length > 0) {
throw new Error(
`entry owner mismatch: ${foreign.map((r) => `${r.id}(${r.owner_id})`).join(', ')} != ${recordOwnerId}`,
);
}
}
return new Map(rows.map((r) => [r.id, r.content]));
}
export interface RecordResult {
record_id: string;
template_id: string;
values: Record<string, string>;
/** 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}`);
const slots: string[] = JSON.parse(tpl.slots_json);
const recordId = input.record_id ?? uid('rec');
const values = input.values ?? {};
const entryIds = input.entry_ids ?? {};
const refSlots = Object.keys(entryIds);
const ownerId = input.owner_id ?? null;
// 同一個 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 那條「靜默略過」——
// 指標設了卻無聲消失是最難查的失敗)。
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, ownerId);
// 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) {
// 指向既有 entry:只插一條關係列,不碰內容(這就是指標)。
await insertCellRelation(db, recordId, tpl.id, slot, entryIds[slot], ownerId);
continue;
}
const entry = await createEntry(db, {
content: values[slot],
entry_type: 'value',
owner_id: ownerId,
});
await insertCellRelation(db, recordId, tpl.id, slot, entry.id, ownerId);
}
// 回傳值:舊路徑照舊原樣回 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: ownerId };
}
// 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> {
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 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 }>();
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);
}
// 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) : [...slotToEntries.keys()];
for (const [slot, content] of Object.entries(values)) {
if (!allowed.includes(slot)) {
throw new Error(`slot not in template: ${slot}`);
}
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 → growentry 關係列)
await ensureFieldEntries(db, templateId, [slot]);
const entry = await createEntry(db, { content, entry_type: 'value', owner_id: recordOwnerId });
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 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 }>();
const values: Record<string, string> = {};
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 [];
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 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 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 }>();
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 [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,指到的內容 entry
* 只在「已經沒有任何關係指著、也不自己發出關係」時才刪。回 false = record 不存在。
*
* 🔴 **還有別人指著的 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 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<{ 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 = ?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;
}