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>
55 lines
2.6 KiB
TypeScript
55 lines
2.6 KiB
TypeScript
// 關係列孤兒巡檢(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 };
|
||
}
|