3d3973ecbc
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>
92 lines
4.2 KiB
TypeScript
92 lines
4.2 KiB
TypeScript
/**
|
||
* asset-keys 單元測試 — 「哪些 KV key 是使用者的資產」這張表本身
|
||
*
|
||
* KV 退休(Leo/Arcrun#16 + #17)。這支測的是純函式,沒有 KV / KBDB / 網路,
|
||
* 因為它要守的東西也很單純:**分類錯了,資產就會被留在會被換掉的那一層。**
|
||
*
|
||
* 兩個方向都要測,而且分量一樣重:
|
||
* - 資產不可以被誤判成衍生(漏收=下次更新就不見了,這是 2026-08-12 的病)
|
||
* - 衍生不可以被誤判成資產(多收=KBDB 長出算得出來的垃圾列)
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
import { classifyAssetKey, classifyListPrefix, assetKvKey } from '../src/lib/asset-keys';
|
||
import { CRON_INDEX_KEY } from '../src/lib/cron-index';
|
||
|
||
describe('classifyAssetKey — 資產', () => {
|
||
it('具名工作流 {api_key}:wf:{name} → workflow_def,租戶與名字都拆得出來', () => {
|
||
const ref = classifyAssetKey('leo:wf:rag_chat');
|
||
expect(ref).not.toBeNull();
|
||
expect(ref!.entry_type).toBe('workflow_def');
|
||
expect(ref!.owner_id).toBe('leo');
|
||
expect(ref!.page_name).toBe('rag_chat');
|
||
expect(ref!.kv_key).toBe('leo:wf:rag_chat');
|
||
});
|
||
|
||
it('api recipe(uuid key 與 migration 前的 canonical key 都算資產)', () => {
|
||
expect(classifyAssetKey('recipe:8f3b-uuid')!.entry_type).toBe('api_recipe');
|
||
expect(classifyAssetKey('recipe:telegram_send')!.entry_type).toBe('api_recipe');
|
||
});
|
||
|
||
it('auth recipe / prompt recipe', () => {
|
||
expect(classifyAssetKey('auth_recipe:notion')!.entry_type).toBe('auth_recipe');
|
||
expect(classifyAssetKey('auth_recipe:notion')!.page_name).toBe('notion');
|
||
expect(classifyAssetKey('prompt_recipe:wiki_synthesis')!.entry_type).toBe('prompt_recipe');
|
||
});
|
||
|
||
it('同一個 key 永遠對到同一個 entry_id(冪等的根據——重跑遷移不會長出重複列)', () => {
|
||
expect(classifyAssetKey('leo:wf:a')!.entry_id).toBe(classifyAssetKey('leo:wf:a')!.entry_id);
|
||
expect(classifyAssetKey('leo:wf:a')!.entry_id).not.toBe(classifyAssetKey('evan:wf:a')!.entry_id);
|
||
});
|
||
});
|
||
|
||
describe('classifyAssetKey — 衍生資料不可誤收', () => {
|
||
it('recipe 反查索引 idx:* 全部不是資產(算得回來,見 rehydrateRecipeIndices)', () => {
|
||
expect(classifyAssetKey('idx:rec_f7e2a1b3')).toBeNull();
|
||
expect(classifyAssetKey('idx:canonical:telegram_send')).toBeNull();
|
||
expect(classifyAssetKey('idx:installed:telegram_send')).toBeNull();
|
||
});
|
||
|
||
it('cron 索引不是資產', () => {
|
||
expect(classifyAssetKey(CRON_INDEX_KEY)).toBeNull();
|
||
expect(classifyAssetKey('cron-idx:leo:daily')).toBeNull();
|
||
});
|
||
|
||
it('匿名 webhook token / 其他暫存 key 不是資產(本卷不碰,非漏收)', () => {
|
||
expect(classifyAssetKey('a1b2c3d4e5f6')).toBeNull();
|
||
expect(classifyAssetKey('daemon-active:leo')).toBeNull();
|
||
});
|
||
|
||
it('殘缺的 key 不當資產(寧可退回原本的 KV 行為,也不要建出半截的列)', () => {
|
||
expect(classifyAssetKey('recipe:')).toBeNull();
|
||
expect(classifyAssetKey('auth_recipe:')).toBeNull();
|
||
expect(classifyAssetKey(':wf:orphan')).toBeNull();
|
||
expect(classifyAssetKey('leo:wf:')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('assetKvKey — 從 KBDB 反推回 KV key(回填快取與 list 都靠它)', () => {
|
||
it('四型都能原路折返', () => {
|
||
for (const key of ['leo:wf:rag_chat', 'recipe:telegram_send', 'auth_recipe:notion', 'prompt_recipe:x']) {
|
||
const ref = classifyAssetKey(key)!;
|
||
expect(assetKvKey(ref.entry_type, ref.owner_id, ref.page_name)).toBe(key);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('classifyListPrefix — 列舉走哪一邊', () => {
|
||
it('租戶工作流列舉 → 走 KBDB(帶 owner)', () => {
|
||
expect(classifyListPrefix('leo:wf:')).toEqual({ entry_type: 'workflow_def', owner_id: 'leo' });
|
||
});
|
||
|
||
it('recipe / auth_recipe 列舉 → 走 KBDB', () => {
|
||
expect(classifyListPrefix('recipe:')).toEqual({ entry_type: 'api_recipe' });
|
||
expect(classifyListPrefix('auth_recipe:')).toEqual({ entry_type: 'auth_recipe' });
|
||
});
|
||
|
||
it('索引與無 prefix 的全域列舉 → 維持原本的 KV 行為', () => {
|
||
expect(classifyListPrefix('idx:')).toBeNull();
|
||
expect(classifyListPrefix('cron-idx:')).toBeNull();
|
||
expect(classifyListPrefix(undefined)).toBeNull();
|
||
});
|
||
});
|