// 關係列孤兒巡檢(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 { 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(); const rows = res.results ?? []; const truncated = rows.length > cap; const orphans = truncated ? rows.slice(0, cap) : rows; return { orphans, count: orphans.length, truncated }; }