Files
Arcrun/cypher-executor/tests/asset-keys.test.ts
T
uncle6me-web 3d3973ecbc 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>
2026-08-12 16:51:58 +08:00

92 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 recipeuuid 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();
});
});