ceb7638d74
規格: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>
452 lines
20 KiB
TypeScript
452 lines
20 KiB
TypeScript
// 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';
|
||
|
||
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 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;
|
||
}
|
||
|
||
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 已是池中既有 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) {
|
||
// 指向既有 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 的 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) : [...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 → grow(entry + 關係列)
|
||
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_id(createRecord 寫入時同值,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 是既有 block(library_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();
|
||
// 裸身分 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 = ?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;
|
||
}
|