feat(storage): 工作流與 recipe 的家搬到 KBDB,KV 降成可丟棄的快取(Arcrun#16+#17)
leo 08-12:「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,
數據,一個 entry」「如果零件和工作流的 recipe 不見了,是很可怕的事情」。
同日實害:一次例行更新讓九支工作流在畫面上全部消失。#97 已修掉直接原因
(別再照名字猜使用者的資源、別再擅自新建一顆空的綁上去);這裡修更下面那一句——
**資產本來就不該只存在於一個會被換掉的暫存層裡**。
做法(換 binding,不改四十幾處呼叫端):
- lib/asset-keys.ts 哪些 KV key 是資產、對應 KBDB 哪一列。**唯一**要人看懂的那張表。
- lib/durable-store.ts 讀=KV 先行、miss 回源 KBDB 並補快取;寫=先 KBDB 再 KV,
KBDB 失敗就拋錯(禁假綠);**列舉一律回源**——空 KV 列出來是
「零筆」而不是「查不到」,那正是東西消失的形狀。
- index.ts 入口把 WEBHOOKS/RECIPES 換成上面那層。逐處改寫一定會漏,
而漏掉的那一處就是下一次「東西不見了」的入口。
- routes/storage.ts /storage/audit(搬前搬後各數一次)+ /storage/migrate-to-kbdb
(只增不刪、冪等、逐筆回報成敗)。
- kbdb migration 0005 seed 四列 template(零 schema 異動,手法同 0003/0004)
+ PUT /entries/:id 指定 id 的整列 upsert(通用原語,不是為誰開特例)。
- 衍生資料(idx:*、cron-idx:_all)不進 KBDB,讀不到就從資產重算。
⚠️ 狀態=◐ 半通,**別因為程式碼看起來完整就先合併**。
已實測:5 份 migration 在本機 D1 全數套用(含 0005);兩顆 worker 都能以改動後的
程式碼在本機開起來;tsc 錯誤數 7→7(既有,未新增)。
**沒跑到**:「砍掉 KV、資產還在」那一次端到端驗證——本次施工環境的權限閘不放行
執行 vitest/node/curl。那一次已寫成 scripts/verify-kv-retirement.sh,
在能執行的機器上跑一次就是證據。建議順序:先跑腳本、綠了再合併。
規格層依 D35 走 pending-changes.md「P-KV」提案,等 leo confirm(現行 active SDD
是 workflow-discovery,本案不在它的 tasks 內,故不自建 SDD、不改 rules 那張儲存表)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
-- arcrun 資產型別 template seed — KV 退休(Leo/Arcrun#16 + #17)
|
||||
--
|
||||
-- 為什麼有這一檔(leo 2026-08-12 原話):
|
||||
-- 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
|
||||
-- 「如果零件和工作流的 recipe 不見了,是很可怕的事情」
|
||||
-- 同一天(2026-08-12)真的發作過:一次例行更新讓使用者的九支工作流在畫面上全部消失
|
||||
-- (根因見 cli/src/lib/resource-resolver.ts 檔頭 Arcrun#97——舊 deploy 會照名字新建一顆空 KV
|
||||
-- 再綁上去)。#97 修的是「不要再把 worker 綁到空的資源上」;本卷修的是更根本的一句:
|
||||
-- **使用者的資產本來就不該只存在於一個會被換掉的暫存層裡。**
|
||||
--
|
||||
-- KBDB 鐵律(leo 2026-06-14,D38):三張表打天下,永遠不加新 table,新資料類型一律用 template。
|
||||
-- 本檔**零 schema 異動**——只 INSERT OR IGNORE 四列 template 定義,手法與同目錄
|
||||
-- 0003_library_map.sql / 0004_execution_log_template.sql 完全相同。
|
||||
--
|
||||
-- 儲存精神比照 0004(execution_log)與 recipe-stat:template 只負責「schema 文件化 +
|
||||
-- GET /templates 可發現」,實際一筆資產是 entries 表的**一列**——
|
||||
-- entry_type = 'workflow_def' | 'api_recipe' | 'auth_recipe' | 'prompt_recipe'
|
||||
-- owner_id = 租戶(workflow 才有;recipe 是整台實例共用的庫,故為 NULL)
|
||||
-- page_name = 該型別的自然鍵(workflow 名 / recipe uuid / service 名)
|
||||
-- content = 給人看也給語意搜尋看的一句描述
|
||||
-- metadata_json = 定義本體(graph / endpoint / inject … 原樣 JSON)
|
||||
-- ——不走 entry_values 全展開的多列 record:一支 workflow 的 graph 是一整包巢狀 JSON,
|
||||
-- 拆成 slot 多列既不會變得比較好查,反而讓「一筆資產=一列」這件事不再成立
|
||||
-- (recipe_stat 與 execution_log 早已示範「template 存在 + entries 直接存」這個模式合法)。
|
||||
--
|
||||
-- 讀寫一律走 HTTP API(/entries、/entries/:id),呼叫端是 cypher-executor 的
|
||||
-- src/lib/durable-store.ts。牆外沒有任何一行 SQL。
|
||||
|
||||
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
|
||||
VALUES
|
||||
('tpl-workflow-def', 'workflow_def',
|
||||
'工作流定義本體(KV 退休 #17)。一支工作流=entries 一列;graph/config/cron_expr 打包進 metadata_json,WEBHOOKS KV 降為可丟棄的快取',
|
||||
'["name","description","graph","config","cron_expr","created_at"]', 'system'),
|
||||
|
||||
('tpl-api-recipe', 'api_recipe',
|
||||
'API recipe 定義本體(KV 退休 #16)。一份 recipe=entries 一列;endpoint/headers/body/auth 等打包進 metadata_json,RECIPES KV 降為快取。idx:* 反查索引屬衍生資料,不進 KBDB,由 durable-store 從本型別重建',
|
||||
'["uuid","canonical_id","hash_id","author","endpoint","method","auth_service","installed"]', 'system'),
|
||||
|
||||
('tpl-auth-recipe', 'auth_recipe',
|
||||
'Auth recipe 定義本體(KV 退休 #16)。一個服務一列;primitive/base_url/required_secrets/inject 打包進 metadata_json。只存「怎麼認證」,不存任何密文(憑證明文在 CF Workers Secrets,見 .claude/rules/01-tech-stack.md)',
|
||||
'["service","primitive","base_url","version","required_secrets","inject"]', 'system'),
|
||||
|
||||
('tpl-prompt-recipe', 'prompt_recipe',
|
||||
'Prompt recipe 定義本體(KV 退休 #16)。一份 prompt recipe=entries 一列,定義打包進 metadata_json',
|
||||
'["name","definition"]', 'system');
|
||||
@@ -134,6 +134,51 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* 以「呼叫端指定的 id」寫一列——已存在就整列覆蓋,不存在就新建(KV 退休 #16/#17)。
|
||||
*
|
||||
* 為什麼 base 需要這一支:`createEntry` 的 id 預設是隨機的,同一份資產每存一次就多一列;
|
||||
* 呼叫端若想要「同一份資產永遠是同一列」,只能自己先 GET 再決定 POST 還是 PATCH——
|
||||
* 兩趟往返、而且中間有競態。把它收成一個原語,語意才單一(冪等:同樣的輸入跑幾次結果都一樣)。
|
||||
*
|
||||
* 這是 base 的通用能力,不是替某個 entry_type 開的特例——任何有天然鍵的資料型別
|
||||
* (arcrun 的 workflow_def / api_recipe / auth_recipe,或未來別的)都用得上。
|
||||
* **零 schema 異動**:仍然只寫 entries 這一張既有的表。
|
||||
*
|
||||
* 覆蓋語意刻意是「整列取代」而非 PATCH 式合併:呼叫端手上是一份完整的資產定義,
|
||||
* 合併語意會讓「刪掉某個欄位」變成做不到的事(舊值會留下來)。
|
||||
* created_at 保留原值(資產的誕生時間不因為改一次內容就被重寫),updated_at 更新。
|
||||
*/
|
||||
export async function upsertEntry(db: D1Database, id: string, input: Omit<CreateEntryInput, 'id'>): Promise<Entry> {
|
||||
const existing = await getEntry(db, id);
|
||||
if (!existing) return createEntry(db, { ...input, id });
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE entries
|
||||
SET content = ?, entry_type = ?, owner_id = ?, parent_id = ?, page_name = ?,
|
||||
refs_json = ?, tags_json = ?, task_status = ?, confidence = ?, metadata_json = ?,
|
||||
updated_at = unixepoch()
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
input.content ?? null,
|
||||
input.entry_type,
|
||||
input.owner_id ?? null,
|
||||
input.parent_id ?? null,
|
||||
input.page_name ?? null,
|
||||
input.refs_json ?? '[]',
|
||||
input.tags_json ?? '[]',
|
||||
input.task_status ?? null,
|
||||
input.confidence ?? null,
|
||||
input.metadata_json ?? null,
|
||||
id,
|
||||
)
|
||||
.run();
|
||||
const row = await getEntry(db, id);
|
||||
if (!row) throw new Error('upsertEntry: update succeeded but row not found');
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把某 owner 下某庫的所有 entries 標 deprecated(t135 by-name 移除語意)。
|
||||
* 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getEntry,
|
||||
listEntries,
|
||||
updateEntry,
|
||||
upsertEntry,
|
||||
deleteEntry,
|
||||
searchEntries,
|
||||
isDeprecatedEntry,
|
||||
@@ -426,6 +427,21 @@ entryRoutes.get('/backfill-library/status', async (c) => {
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// PUT /entries/:id — 以呼叫端指定的 id 整列覆寫(不存在就新建)。KV 退休 #16/#17。
|
||||
//
|
||||
// 與 POST / 的差別:POST 的 id 是隨機的,同一份資產每存一次多一列;PUT 讓「同一份資產永遠
|
||||
// 是同一列」,所以重跑遷移、重複部署同一支工作流都不會長出重複資料(冪等)。
|
||||
// 與 PATCH /:id 的差別:PATCH 是部分更新(沒帶的欄位留著),PUT 是整列取代
|
||||
// ——呼叫端手上是完整定義時要的是後者,否則「刪掉一個欄位」永遠做不到。
|
||||
entryRoutes.put('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400);
|
||||
const entry = await upsertEntry(c.env.DB, c.req.param('id'), body);
|
||||
// 與 POST / 同款:標了 embed:true 的才進 Vectorize,fire-and-forget、失敗不致命。
|
||||
if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
|
||||
return c.json({ success: true, entry });
|
||||
});
|
||||
|
||||
// PATCH /entries/:id
|
||||
entryRoutes.patch('/:id', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
|
||||
Reference in New Issue
Block a user