feat: component hash IDs + dynamic KV recipe system

Hash system:
- cmp_xxxxxxxx: stable ID for logic components (SHA-256 of canonical_id)
- rec_xxxxxxxx: stable ID for API recipe components
- Pre-seeded 15 cmp_ + 6 rec_ hash indexes in KV

RECIPES KV (id: 9cf9db905c6241f78503199e58b2ffe0):
- POST/GET/DELETE /recipes — CRUD for API recipe definitions
- recipe stored as: recipe:{canonical_id} + idx:{rec_hash}
- template interpolation: {{key}} replaced from context

component-loader resolution order:
  builtin → external URL → cmp_ hash → rec_ hash →
  logic canonical_id → KV recipe → builtin API fallback → error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 18:36:51 +08:00
parent d8e6964088
commit 60d3e41905
6 changed files with 345 additions and 146 deletions
+125
View File
@@ -0,0 +1,125 @@
/**
* /recipes — API recipe CRUD
*
* recipe 是「http_request + 參數模板」的具名封裝。
* 不需要 deploy Worker,執行時由 cypher-executor 直接 fetch。
*
* KV 結構:
* recipe:{canonical_id} → RecipeDefinition JSON
* idx:{rec_hash} → canonical_id (反查索引)
*
* 引用方式(workflow config):
* component: "rec_f7e2a1b3" → 永久穩定,不受改名影響
* component: "slack" → 向前兼容,直接用 canonical_id 查
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { deriveRecipeHash } from '../lib/hash';
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
export interface RecipeDefinition {
canonical_id: string;
hash_id: string; // rec_xxxxxxxx
display_name?: string;
description?: string;
endpoint: string;
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
headers?: Record<string, string>;
body?: Record<string, unknown>;
credentials_required?: Array<{
key: string;
inject_as: string;
}>;
created_at: number;
updated_at: number;
}
// POST /recipes — 新增或更新 recipe
recipesRouter.post('/recipes', async (c) => {
let body: Partial<RecipeDefinition>;
try {
body = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
}
const canonicalId = (body.canonical_id ?? '').trim().toLowerCase();
if (!canonicalId) return c.json({ success: false, error: 'canonical_id 必填' }, 400);
if (!body.endpoint) return c.json({ success: false, error: 'endpoint 必填' }, 400);
const hashId = await deriveRecipeHash(canonicalId);
const now = Date.now();
// 讀取現有版本(保留 created_at
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
const recipe: RecipeDefinition = {
canonical_id: canonicalId,
hash_id: hashId,
display_name: body.display_name,
description: body.description,
endpoint: body.endpoint,
method: (body.method ?? 'POST').toUpperCase(),
headers: body.headers,
body: body.body,
credentials_required: body.credentials_required,
created_at: existing?.created_at ?? now,
updated_at: now,
};
// 寫入兩個 KV key
await Promise.all([
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
]);
return c.json({ success: true, recipe });
});
// GET /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash
recipesRouter.get('/recipes/:id', async (c) => {
const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
return c.json({ success: true, recipe });
});
// GET /recipes — 列出所有 recipe
recipesRouter.get('/recipes', async (c) => {
const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
const recipes = await Promise.all(
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
);
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
});
// DELETE /recipes/:id — 刪除 recipe
recipesRouter.delete('/recipes/:id', async (c) => {
const id = c.req.param('id');
const recipe = await resolveRecipe(id, c.env.RECIPES);
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
await Promise.all([
c.env.RECIPES.delete(`recipe:${recipe.canonical_id}`),
c.env.RECIPES.delete(`idx:${recipe.hash_id}`),
]);
return c.json({ success: true, deleted: recipe.canonical_id });
});
/** 用 canonical_id 或 rec_hash 查 recipe */
export async function resolveRecipe(
id: string,
kv: KVNamespace,
): Promise<RecipeDefinition | null> {
// rec_xxxxxxxx → 先查 idx 反查 canonical_id
if (id.startsWith('rec_')) {
const canonicalId = await kv.get(`idx:${id}`);
if (!canonicalId) return null;
return kv.get(`recipe:${canonicalId}`, 'json');
}
// 直接用 canonical_id
return kv.get(`recipe:${id}`, 'json');
}