From ceb7638d746f0e8847552aef721f6f53ef87ff0b Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 15 Aug 2026 21:34:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(kbdb):=20=E6=A8=B9=E7=8B=80=20record=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=AC=AC=E4=B8=80=E5=88=80=E2=80=94=E2=80=94?= =?UTF-8?q?record=20=E6=9C=89=E8=BA=AB=E5=88=86=E3=80=81=E9=97=9C=E4=BF=82?= =?UTF-8?q?=E6=98=AF=E5=94=AF=E4=B8=80=E6=A9=9F=E5=88=B6=E3=80=81entry=5Fv?= =?UTF-8?q?alues=20=E6=8B=86=E8=A1=A8=EF=BC=88v7=20=E5=AE=9A=E7=A8=BF?= =?UTF-8?q?=E5=AF=A6=E4=BD=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 規格: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 --- cli/src/lib/deploy.ts | 45 ++ kbdb/migrations/0007_tree_record_model.sql | 138 ++++++ kbdb/src/actions/entry-crud.ts | 20 + kbdb/src/actions/library-map.ts | 92 ++-- kbdb/src/actions/record-crud.ts | 399 +++++++++++------- kbdb/src/actions/relation-orphans.ts | 54 +++ kbdb/src/index.ts | 10 + kbdb/src/routes/records.ts | 26 +- kbdb/src/types.ts | 16 +- .../tests/credential-legacy-migration.test.ts | 6 +- kbdb/tests/embed-backfill.test.ts | 1 + kbdb/tests/embed-selftest.test.ts | 2 +- kbdb/tests/execution-log.test.ts | 9 +- kbdb/tests/library-backfill.test.ts | 1 + kbdb/tests/library-filter.test.ts | 2 +- kbdb/tests/library-map.test.ts | 1 + kbdb/tests/record-entry-ref.test.ts | 11 +- kbdb/tests/record-grow-owner.test.ts | 29 +- kbdb/tests/search-deprecated-filter.test.ts | 2 +- kbdb/tests/search-like-escape.test.ts | 5 +- kbdb/tests/search-semantic-degraded.test.ts | 2 +- .../search-semantic-empty-reason.test.ts | 2 +- kbdb/tests/search-source-and-score.test.ts | 2 +- kbdb/tests/tree-record-migration.test.ts | 154 +++++++ kbdb/tests/triplet-library-backfill.test.ts | 1 + 25 files changed, 799 insertions(+), 231 deletions(-) create mode 100644 kbdb/migrations/0007_tree_record_model.sql create mode 100644 kbdb/src/actions/relation-orphans.ts create mode 100644 kbdb/tests/tree-record-migration.test.ts 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_