diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index ce65c2d..6af175d 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -721,6 +721,22 @@ export async function downloadAndDeploy( } else { failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql(${execLogMigPath})`); } + + // 3.8 樹狀 record 模型(0007,v7 定稿 2026-08-15):record 有身分、關係是唯一機制、 + // entry_values 拆表。**必須排在所有 template seed 之後**(它把 templates 表既有列 + // 鏡射成池中 sheet/field entry)。逐句套用+容錯 duplicate column:檔內三句 + // ADD COLUMN 在 SQLite 沒有 IF NOT EXISTS 形式,重跑(每次部署都會重跑本段)時 + // 那三句報 duplicate column = 已套用,其餘語句全部語句級冪等(檔頭有完整說明)。 + const treeMigPath = join(root, 'kbdb', 'migrations', '0007_tree_record_model.sql'); + if (existsSync(treeMigPath)) { + try { + await applyD1MigrationTolerant(ctx, readFileSync(treeMigPath, 'utf8')); + } catch (e) { + failures.push(`D1 migration 0007_tree_record_model (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`); + } + } else { + failures.push(`D1 migration: 部署物缺 kbdb/migrations/0007_tree_record_model.sql(${treeMigPath})`); + } } const cypherExecutorUrl = ctx.workerSubdomain @@ -751,6 +767,35 @@ export async function downloadAndDeploy( }; } +/** + * 逐句套 migration,容錯 duplicate column(0007 專用)。 + * + * 為什麼不能走 applyD1Migration 整檔送:/query 端點任何一句失敗整批中止—— + * 0007 的三句 ADD COLUMN 在重跑時必然報 duplicate column(SQLite 沒有欄位級 + * IF NOT EXISTS),整檔送 ⇒ 第二次部署起 migration 永遠假紅、後面的資料搬遷 + * 語句永遠不被執行。逐句+把 duplicate column 視為「已套用」,其餘錯誤照樣拋。 + * 切句手法與安裝器 compile-migrations.mjs 同款(剝 -- 註解、依分號切; + * 0007 的字串常值不含分號,前提成立)。 + */ +async function applyD1MigrationTolerant(ctx: DeployContext, sql: string): Promise { + const statements = sql + .split('\n') + .map((l) => l.replace(/--.*$/, '')) + .join('\n') + .split(';') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + for (const stmt of statements) { + try { + await applyD1Migration(ctx, stmt); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用 + throw new Error(`${stmt.slice(0, 60)}… → ${msg}`); + } + } +} + /** * 對 D1 套 SQL migration(透過 CF API `/d1/database/{id}/query`,非 wrangler)。 * 用 init 已驗的 ctx.apiToken + accountId;query 端點接受多語句檔,一次送整份 0001_base.sql。 diff --git a/kbdb/migrations/0007_tree_record_model.sql b/kbdb/migrations/0007_tree_record_model.sql new file mode 100644 index 0000000..9eff8a8 --- /dev/null +++ b/kbdb/migrations/0007_tree_record_model.sql @@ -0,0 +1,138 @@ +-- 0007 — record 有身分,關係是唯一機制(樹狀 record 模型第一刀) +-- 規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm) +-- +-- 模型一句話(leo 定案):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」 +-- · record = 池中一顆有身分的 entry(沿用原 record_id 字串當 id,既有引用不失效) +-- · 欄位是關係、歸屬也是關係——同一種機制:一列關係 = src/rel/dst 三個型別化指標欄 +-- (「池上型別化指標欄」,parent_id 是這族欄位的既有先例。紅線:不准用關係實作關係、 +-- 不准把指標塞回 content/JSON——那是 D91 的位置換個門進來) +-- · 舊 entry_values 四欄的下場:record_id → record entry 自己的 id/entry_id → 指標終點(dst) +-- /slot_name → 指標謂詞(rel = field entry)/template_id → 一條屬於關係(rel = sys_belongs) +-- · 那張表該死的理由:它是「關係」的第二套實作——同一件事兩個實作必然漂移(D92) +-- +-- 資料遷移形狀(v7 §3):既有 value entries 一列都不動(id 穩定、向量索引不失效)、 +-- 全部純 INSERT、零 UPDATE。以舊儲存格列 id 衍生關係列 id ⇒ INSERT OR IGNORE 天然冪等。 +-- +-- 冪等設計(三條套用路徑:官方 wrangler migrations/cli deploy.ts 每次重跑/安裝器逐句重放): +-- · 除了三句 ADD COLUMN 之外,每一句都是語句級冪等(IF NOT EXISTS/INSERT OR IGNORE) +-- · SQLite 沒有「ADD COLUMN IF NOT EXISTS」——重跑時那三句會報 duplicate column, +-- 套用端必須把「duplicate column」視為已套用(cli deploy.ts 的 applyD1MigrationTolerant、 +-- 安裝器逐句 try/catch 本就容錯)。官方路徑走 wrangler migrations 追蹤表,只跑一次。 +-- · 不走「重建整張 entries」的做法:那雖然能純語句冪等,但 deploy.ts 每次部署都重跑 +-- migration ⇒ 每次部署全表複製一輪,直接吃掉 D1 每日列寫入額度。 +-- · entry_values 拆表用 0006 的「墊表、搬、拆」手法:重跑時先墊一份空殼,搬 0 筆,再拆,無害。 +-- +-- 施工窗口注意:本檔跑完 entry_values 就不存在了,必須與讀寫端改版(kbdb/src 同一批)一起部署。 +-- 舊 worker 碰新庫會炸 entry_values 不存在——這是刻意的(leo:「不做長期雙讀相容層,一次翻」)。 + +-- ============================================================ +-- 1. 關係的物理載體:池上型別化指標欄 + 一對方向索引 +-- ============================================================ + +ALTER TABLE entries ADD COLUMN src_id TEXT; -- kbdb-sql-ok: 牆內 migration 本體(v7 定案載體「池上型別化指標欄」),重跑由套用端容錯 duplicate column +ALTER TABLE entries ADD COLUMN rel_id TEXT; -- kbdb-sql-ok: 同上 +ALTER TABLE entries ADD COLUMN dst_id TEXT; -- kbdb-sql-ok: 同上 + +-- 一對方向索引(v7 §2-1):src 端服務「r1 的所有欄位」,(dst,rel) 端服務 +-- 「某張 sheet 的所有 record」(rel=屬於, dst=sheet)與「誰指到 e1」(dst=e1)。 +-- partial index:內容 entry(指標欄全 NULL)不進索引,索引大小 ≈ 關係列數。 +CREATE INDEX IF NOT EXISTS idx_entries_rel_src ON entries(src_id) WHERE src_id IS NOT NULL; -- kbdb-sql-ok: 牆內 migration 本體,普通欄位索引(0001 十條索引的同族) +CREATE INDEX IF NOT EXISTS idx_entries_rel_dst ON entries(dst_id, rel_id) WHERE dst_id IS NOT NULL; -- kbdb-sql-ok: 同上 + +-- ============================================================ +-- 2. 啟動常數(v7 §2-3:一組、極小、只讀) +-- ============================================================ + +-- 保留根:屬於鏈的終點。某顆 entry ─屬於→ sys_root = 它是一張 sheet。 +INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_root', 'root', 'system', NULL); +-- 「屬於」謂詞:record ─屬於→ sheet(歸屬關係的 rel)。 +INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_belongs', 'belongs', 'system', NULL); +-- 「欄位屬於表」謂詞:field ─field_of→ sheet(sheet 的欄位名冊,與 record 歸屬分開, +-- 免得「某張 sheet 的所有 record」把欄位也撈進來)。 +INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_field_of', 'field_of', 'system', NULL); + +-- ============================================================ +-- 3. 墊表(0006 手法):本檔尾端會拆掉 entry_values,重跑時先墊空殼 +-- 讓下面的搬遷語句永遠合法(搬 0 筆),最後再拆一次。 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS entry_values ( -- kbdb-sql-ok: 表退場施工步驟①保底存在(0006 同款),非資料存取違規 + id TEXT PRIMARY KEY, + record_id TEXT NOT NULL, + template_id TEXT NOT NULL, + slot_name TEXT NOT NULL, + entry_id TEXT NOT NULL, + created_at INTEGER DEFAULT (unixepoch()), + UNIQUE(record_id, slot_name) +); + +-- ============================================================ +-- 4. sheet/field 落池(templates 表遷移期雙軌:欄位定義的真相源暫仍在 templates 表, +-- sheet/field entry 是它在池中的身分——第二刀把 templates 表整個退役) +-- ============================================================ + +-- 每個 template 一顆 sheet entry(id 沿用 template id,既有引用不失效) +INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id, created_at, updated_at) +SELECT t.id, t.name, 'sheet', NULL, t.created_at, t.updated_at FROM templates t; + +-- sheet ─屬於→ 保留根(「什麼讓一顆 entry 成為 sheet」的答案:這條關係) +INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) +SELECT 'relb_' || t.id, 'relation', t.id, 'sys_belongs', 'sys_root' FROM templates t; + +-- 每個 slot 一顆 field entry(id 決定性衍生:fld_