// credential-legacy-migration.ts — 「新讀取端上線、舊資料還沒搬完」的自癒補丁 // (D38 圍牆修復收尾,總管交辦,2026-08-08)。 // // ── 為什麼這支檔案存在 ──────────────────────────────────────────────────── // 7ba7855(D38 圍牆修復)把 credential 目錄的讀寫端從舊表 `credentials`(0002,違規多開 // 的第四張表)改成走 entries 表(entry_type='credential')。0006_drop_credentials_table.sql // 寫了「把舊表資料搬進 entries 後讓舊表退場」的一次性 migration,但這支 migration **要有人 // 手動觸發部署才會跑**——2026-08-07 youlin 測試實例的事故就是「code 部署了、migration 沒 // 跑」造成 20/20 workflow 全部找不到 credential。 // // leo 追加的硬要求(2026-08-08):credential 資料住在**用戶自己的 Cloudflare 帳號**, // 換讀取路徑=每個既有實例的資料都要跟著搬,但**用戶不准做任何手動步驟**——不能要求他 // 跑指令、改設定、重裝。搬遷必須內建在「用戶本來就會走的路」裡(因此天然無感)。 // // ── 解法:把「搬」變成「讀」的副作用,而不是獨立一步 ───────────────────── // KBDB worker(本檔)是 D38 唯一允許碰 SQL 的地方(牆內)。這裡在**每次查詢某租戶的 // credential 目錄之前**,先確認舊表資料是否已經搬進 entries——沒有就搬(scoped 到這個 // owner_id,NOT EXISTS 防重複),有就是零成本的一次 sqlite_master 檢查。 // // 呼叫時機只有一個:cypher-executor 的 credentials.ts 熱路徑(getCredentialDirectory / // findCredentialEntry)本來就會在**每次 workflow 執行**打一次 GET /entries?entry_type= // credential&owner_id=X(60 秒快取未命中時)。只要 KBDB worker 部署了本檔的邏輯, // 下一次任何人跑 workflow,那個租戶的資料就自動搬好了——**不需要用戶多做任何事**, // 也不需要「更新流程」額外呼叫一支新端點:更新 KBDB worker 本身就是唯一需要發生的事, // 之後的搬遷由使用行為自然觸發。 // // ── 三個安全性質(都經得起故意製造壞狀態來驗證,見 tests/credential-legacy-migration.test.ts)── // 1. 冪等:NOT EXISTS 防止同一筆搬兩次;同一個 owner 呼叫 N 次只搬一次。 // 2. 對「已經搬過」與「還沒搬」的實例都正確:已搬過 → legacyTableExists 一旦舊表被真的 // 清空退場(未來清理步驟)就直接短路回 false,query 零成本;還沒搬 → 這次呼叫就地補齊。 // 3. 不砍表:本檔刻意不執行「讓舊表退場」那句 SQL——多個實例的搬遷時間點不同, // 表還留著才能讓「還沒搬的」與「已經搬的」實例同時安全運作(leo 08-08: // 「他們會同時存在一段時間」)。退場是之後所有租戶都確認搬完才做的獨立清理步驟。 /** 舊表是否還存在(sqlite_master 查詢,索引命中、幾乎零成本)。 * 一旦舊表被清理步驟真的清空退場,這裡會回 false,後續呼叫直接短路,不再嘗試搬遷。 */ async function legacyCredentialsTableExists(db: D1Database): Promise { const row = await db .prepare(`SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = 'credentials'`) .first<{ x: number }>(); return row !== null; } /** * 把某個租戶(owner_id=api_key)在舊 `credentials` 表裡、entries 還沒有對應列的 row * 搬進 entries(entry_type='credential')。scoped 到單一 owner,故查詢便宜,可安全地在 * 熱路徑(每次 workflow 執行)前呼叫。 * * 欄位對應與 0006_drop_credentials_table.sql 逐字一致(page_name=name 冪等鍵, * metadata_json 打包 service/sensitivity/secret_ref/last_used_at)。 * * @returns 實際搬移的筆數(0 = 這個 owner 沒有待搬資料,含「舊表本來就不存在」與 * 「已經搬過」兩種情況——呼叫端不需要分辨,行為一致)。 */ export async function migrateLegacyCredentialsForOwner(db: D1Database, ownerId: string): Promise { if (!ownerId) return 0; // 沒有 owner_id 的查詢(極少見)不觸發:搬遷是 per-tenant 動作,範圍不明確就不做 if (!(await legacyCredentialsTableExists(db))) return 0; // 舊表不存在(從未有 / 已清理)→ 零成本短路 const before = await db .prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'credential' AND owner_id = ?1`) .bind(ownerId) .first<{ n: number }>(); await db .prepare( `INSERT INTO entries (id, entry_type, owner_id, page_name, metadata_json, created_at, updated_at) SELECT 'e_cred_' || lower(hex(randomblob(8))), 'credential', c.api_key, c.name, json_object('service', c.service, 'sensitivity', c.sensitivity, 'secret_ref', c.secret_ref, 'last_used_at', c.last_used_at), c.created_at, unixepoch() FROM credentials c WHERE c.api_key = ?1 AND NOT EXISTS ( SELECT 1 FROM entries e WHERE e.entry_type = 'credential' AND e.owner_id = c.api_key AND e.page_name = c.name )`, ) .bind(ownerId) .run(); const after = await db .prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type = 'credential' AND owner_id = ?1`) .bind(ownerId) .first<{ n: number }>(); return (after?.n ?? 0) - (before?.n ?? 0); }