feat(kbdb): recipe 公庫/私庫雙向機制 + UUID 身份 + KBDB Base + 市場數據
kbdb-base SDD §7.5(公庫/私庫雙向機制,richblack 2026-06-07 拍板)。
## KBDB Base worker(新)
- kbdb/:D1-only 核心三表(entries/templates/entry_values)+ CRUD + LIKE search
+ recipe-stats 端點(市場數據)+ 0001_base.sql migration(含 recipe_stat seed)
## Phase 2.3:init 建 D1 + 套 migration
- cli cf-api.ts 加 listD1Databases/ensureD1Database;init 建 arcrun-kbdb D1
- deploy.ts 部署後對 D1 套 0001_base.sql(CF /d1/query API,idempotent)+ 注入 database_id
## Phase 5.1:recipe 成功記錄(市場數據來源)
- GraphExecutor 收集本次用到的 recipe uuid(usedRecipeKeys)
- executeWebhookGraph 執行結束一次性記 per-uuid 成功/失敗到 KBDB(fire-and-forget)
## Phase 7.5:recipe UUID 身份 + app-store 模型
- recipe 領 uuid=唯一身份;canonical_id/author/公私=屬性(§7.5.5)
- recipe:{uuid} + idx:canonical/installed/hash;resolveRecipe 向後相容不破執行鏈
- POST /recipes/submit=領新 uuid 新增作者版本(非覆蓋,app-store)
- GET /public-recipes 搜尋(多作者+per-uuid 市場星數)/ :id pull(選市場最佳)
- 落空→found:false 創作引導(§7.5.6 閉環)
- POST /recipes/migrate-uuid 一次性轉舊 key(增量寫不刪舊、冪等)
- init-seed 用 UUID(author=system)
## 薄殼(rule 07 §5:CLI + MCP 覆蓋同組能力)
- CLI: acr recipe search/pull/submit-p(config 加 DEFAULT_PUBLIC_LIBRARY_URL)
- MCP: arcrun_recipe_search/pull/submit_p/push/list/delete(補齊漂移)
## 壓測修正
- api-recipe-seeds: google_sheets_append PUT→POST(:append 正確動詞,階段12)
四 worker tsc 全綠(cypher/cli/kbdb/mcp)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,12 @@ import type { ExposureConsent } from '../lib/exposure-consent';
|
||||
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
export interface RecipeDefinition {
|
||||
// UUID 身份模型(kbdb-base §7.5.5):每個 recipe 一誕生領 uuid = 唯一身份。
|
||||
// canonical_id / author / 公私 都是屬性,不是身份。身份(uuid) 與歸屬(author) 分離。
|
||||
// 舊 recipe 無 uuid → resolveRecipe / migration 兼容(migration 增量補 uuid,不刪舊 key)。
|
||||
uuid?: string; // 唯一身份;舊資料可能缺,讀取時容忍
|
||||
author?: string; // 該 uuid 投稿者(誰投誰負責那版市場數據);'system' = init-seed 種子
|
||||
derived_from?: string; // 可選溯源:fork 自哪個 uuid(Leo 改 John 版時記 John 的 uuid)
|
||||
canonical_id: string;
|
||||
hash_id: string; // rec_xxxxxxxx
|
||||
display_name?: string;
|
||||
@@ -47,6 +53,36 @@ export interface RecipeDefinition {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// ── UUID 身份模型 KV key(kbdb-base §7.5.5)────────────────────────────────────
|
||||
// recipe:{uuid} → recipe 本體(唯一身份)
|
||||
// idx:canonical:{canonical_id} → JSON array of uuid(同 canonical 多作者版本並存,公庫用)
|
||||
// idx:installed:{canonical_id} → 單一 uuid(本部署執行時用哪個版本;pull/submit 時定)
|
||||
// idx:{hash_id} → canonical_id(既有 rec_hash 反查,保留)
|
||||
// 舊資料 recipe:{canonical_id} 不刪,resolveRecipe fallback 讀得到(migration 增量補,不破現況)。
|
||||
const kIdxCanonical = (canonicalId: string) => `idx:canonical:${canonicalId}`;
|
||||
const kIdxInstalled = (canonicalId: string) => `idx:installed:${canonicalId}`;
|
||||
|
||||
/**
|
||||
* 寫一份 recipe(UUID 身份模型):給定 recipe 已含 uuid → 寫 recipe:{uuid}、
|
||||
* 把 uuid 併進 idx:canonical:{canonical_id} 清單、設為本部署 installed(執行時用此版本)、
|
||||
* 維護 idx:{hash_id} 反查。private(POST /recipes) 與 public(submit-p) 共用此寫入。
|
||||
*/
|
||||
export async function installRecipeRecord(kv: KVNamespace, recipe: RecipeDefinition): Promise<void> {
|
||||
const uuid = recipe.uuid!;
|
||||
const { canonical_id, hash_id } = recipe;
|
||||
|
||||
const listRaw = await kv.get(kIdxCanonical(canonical_id));
|
||||
const uuids: string[] = listRaw ? JSON.parse(listRaw) : [];
|
||||
if (!uuids.includes(uuid)) uuids.push(uuid);
|
||||
|
||||
await Promise.all([
|
||||
kv.put(`recipe:${uuid}`, JSON.stringify(recipe)),
|
||||
kv.put(kIdxCanonical(canonical_id), JSON.stringify(uuids)),
|
||||
kv.put(kIdxInstalled(canonical_id), uuid),
|
||||
kv.put(`idx:${hash_id}`, canonical_id),
|
||||
]);
|
||||
}
|
||||
|
||||
// POST /recipes — 新增或更新 recipe
|
||||
recipesRouter.post('/recipes', async (c) => {
|
||||
let body: Partial<RecipeDefinition>;
|
||||
@@ -63,8 +99,10 @@ recipesRouter.post('/recipes', async (c) => {
|
||||
const hashId = await deriveRecipeHash(canonicalId);
|
||||
const now = Date.now();
|
||||
|
||||
// 讀取現有版本(保留 created_at + 既有同意憑證)
|
||||
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
|
||||
// 私庫(POST /recipes)= 自己地盤,同 canonical 就地更新自己安裝的那份(沿用既有 uuid)。
|
||||
// 既有 installed → 沿用其 uuid + created_at;無 → 新領 uuid(首次裝這個 canonical)。
|
||||
// 讀取順序:先 UUID 模型(installed→uuid),fallback 舊 key(migration 前的種子)。
|
||||
const existing = await resolveRecipe(canonicalId, c.env.RECIPES);
|
||||
|
||||
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
|
||||
const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent);
|
||||
@@ -73,6 +111,9 @@ recipesRouter.post('/recipes', async (c) => {
|
||||
}
|
||||
|
||||
const recipe: RecipeDefinition = {
|
||||
uuid: existing?.uuid ?? crypto.randomUUID(),
|
||||
author: body.author ?? existing?.author ?? 'local',
|
||||
derived_from: body.derived_from ?? existing?.derived_from,
|
||||
canonical_id: canonicalId,
|
||||
hash_id: hashId,
|
||||
display_name: body.display_name,
|
||||
@@ -88,16 +129,124 @@ recipesRouter.post('/recipes', async (c) => {
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// 寫入兩個 KV key
|
||||
await Promise.all([
|
||||
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
|
||||
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
|
||||
]);
|
||||
|
||||
await installRecipeRecord(c.env.RECIPES, recipe);
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// GET /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash)
|
||||
// POST /recipes/submit — 公共庫投稿(submit-p)。kbdb-base SDD §7.2/§7.3。
|
||||
//
|
||||
// 兩套部署模型:self-hosted cypher = 私庫(直接 POST /recipes 寫自己 KV);
|
||||
// 官方 cypher = 公共庫,外部投稿者把修好的 recipe 送來這個端點。
|
||||
//
|
||||
// app-store / UUID 模型(§7.5.5):submit-p = **新增一個作者版本(領新 uuid)**,
|
||||
// 不覆蓋同 canonical_id。同 canonical 多作者並存(Leo 版、John 版各自 uuid + 市場數據)。
|
||||
// 公共庫 = 暴露面 → 強制 exposure_consent(mindset §6:暴露需人類明示同意)。
|
||||
// 投稿者帶的 stat 只當「存證」(誰在何時投了什麼、聲稱打通幾次),寫進 KBDB 一筆
|
||||
// recipe_submission entry,**不**併進 recipe-stat 真實計數(避免自報數污染市場數據,§7.3)。
|
||||
// 市場信任靠真實使用累積(5.1),不拿投稿者自報數當門檻 → 不造債。
|
||||
recipesRouter.post('/recipes/submit', async (c) => {
|
||||
let body: Partial<RecipeDefinition> & {
|
||||
stat?: { success_count?: number; failure_count?: number };
|
||||
submitter?: string;
|
||||
};
|
||||
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();
|
||||
|
||||
// 公共庫投稿一定是暴露 → 需明示同意(無同意直接擋)。投稿是新版本,不沿用既有同意。
|
||||
const consentError = checkExposureConsent(body.exposure_consent, undefined);
|
||||
if (consentError !== null) {
|
||||
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
|
||||
}
|
||||
|
||||
// app-store 模型:**領新 uuid = 新增作者版本**,不覆蓋既有 canonical(§7.5.5)。
|
||||
const recipe: RecipeDefinition = {
|
||||
uuid: crypto.randomUUID(),
|
||||
author: body.author ?? body.submitter ?? 'anonymous',
|
||||
derived_from: body.derived_from,
|
||||
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,
|
||||
auth_service: body.auth_service,
|
||||
credentials_required: body.credentials_required,
|
||||
exposure_consent: resolveConsentForRecord(body.exposure_consent, undefined),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// 新增作者版本:寫 recipe:{uuid} + 併進 idx:canonical 清單(同 canonical 多版本並存)。
|
||||
// installed 也指向這個新版本(官方部署投稿後預設用最新;market 選擇由 §7.5.5 端點處理)。
|
||||
await installRecipeRecord(c.env.RECIPES, recipe);
|
||||
|
||||
// stat 存證:寫一筆 recipe_submission entry 進 KBDB(不當門檻,當法律歸責軌跡)。
|
||||
// fire-and-forget:存證失敗不擋投稿成功。
|
||||
const kbdbBase = (c.env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
|
||||
const evidence = {
|
||||
content: canonicalId,
|
||||
entry_type: 'recipe_submission',
|
||||
metadata_json: JSON.stringify({
|
||||
uuid: recipe.uuid,
|
||||
canonical_id: canonicalId,
|
||||
author: recipe.author,
|
||||
submitter: body.submitter ?? 'unknown',
|
||||
claimed_stat: body.stat ?? null,
|
||||
submitted_at: now,
|
||||
}),
|
||||
};
|
||||
const kbdbHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) kbdbHeaders['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
c.executionCtx.waitUntil(
|
||||
fetch(`${kbdbBase}/entries`, {
|
||||
method: 'POST',
|
||||
headers: kbdbHeaders,
|
||||
body: JSON.stringify(evidence),
|
||||
}).catch(() => undefined),
|
||||
);
|
||||
|
||||
return c.json({ success: true, recipe, evidence_recorded: true });
|
||||
});
|
||||
|
||||
// POST /recipes/migrate-uuid — 一次性 migration:把 migration 前的舊 key recipe:{canonical_id}
|
||||
// (無 uuid)轉成 UUID 身份模型(§7.5.5)。增量寫、**不刪舊 key**(失敗也不破現況;resolveRecipe
|
||||
// 本就 fallback 舊 key)。冪等:已有 uuid 的跳過。重跑安全。
|
||||
recipesRouter.post('/recipes/migrate-uuid', async (c) => {
|
||||
const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
|
||||
let migrated = 0, skipped = 0;
|
||||
const errors: string[] = [];
|
||||
for (const k of list.keys) {
|
||||
try {
|
||||
const rec = await c.env.RECIPES.get(k.name, 'json') as RecipeDefinition | null;
|
||||
if (!rec || !rec.canonical_id) { skipped++; continue; }
|
||||
if (rec.uuid) { skipped++; continue; } // 已是新模型
|
||||
const migrated_recipe: RecipeDefinition = {
|
||||
...rec,
|
||||
uuid: crypto.randomUUID(),
|
||||
author: rec.author ?? 'system', // 舊種子歸 system
|
||||
};
|
||||
await installRecipeRecord(c.env.RECIPES, migrated_recipe);
|
||||
migrated++;
|
||||
} catch (e) {
|
||||
errors.push(`${k.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
return c.json({ success: errors.length === 0, migrated, skipped, errors });
|
||||
});
|
||||
|
||||
// GET /recipes/:id — 讀取 recipe(支援 canonical_id / rec_hash / uuid)
|
||||
recipesRouter.get('/recipes/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const recipe = await resolveRecipe(id, c.env.RECIPES);
|
||||
@@ -105,42 +254,193 @@ recipesRouter.get('/recipes/:id', async (c) => {
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// GET /recipes — 列出所有 recipe
|
||||
// GET /recipes — 列出所有 recipe(本部署 KV 全部版本,含多作者)。
|
||||
// prefix recipe: 同時命中 recipe:{uuid}(新)與 recipe:{canonical_id}(migration 前舊 key)。
|
||||
// 去重:同 canonical_id 若已有帶 uuid 的版本,捨棄無 uuid 的舊 key 重複項。
|
||||
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 });
|
||||
const all = (await Promise.all(
|
||||
list.keys.map(k => c.env.RECIPES.get(k.name, 'json') as Promise<RecipeDefinition | null>)
|
||||
)).filter(Boolean) as RecipeDefinition[];
|
||||
|
||||
// canonical → 是否已有帶 uuid 的版本
|
||||
const hasUuidVersion = new Set(all.filter(r => r.uuid).map(r => r.canonical_id));
|
||||
const recipes = all.filter(r => r.uuid || !hasUuidVersion.has(r.canonical_id));
|
||||
return c.json({ success: true, recipes, count: recipes.length });
|
||||
});
|
||||
|
||||
// DELETE /recipes/:id — 刪除 recipe
|
||||
// ── 公庫只讀端點(kbdb-base §7.5.4,公→私 pull + 瀏覽的後端基礎)──────────────────
|
||||
// 官方 cypher 開公開只讀(無需 api_key,公庫本就公共)。語意 = 「這是公庫,給 self-hosted pull/瀏覽」,
|
||||
// 含作者維度 + 市場星數(與內部 /recipes 分開命名,公庫的多作者/排序不污染內部)。
|
||||
|
||||
/** 從 KBDB 抓 recipe 市場星數(5.1 記的 success/failure)。失敗回 null(端點仍可用,星數缺省)。*/
|
||||
async function fetchMarketStat(
|
||||
env: Bindings,
|
||||
canonicalId: string,
|
||||
): Promise<{ success_count: number; failure_count: number } | null> {
|
||||
try {
|
||||
const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
|
||||
const headers: Record<string, string> = {};
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
const res = await fetch(`${base}/recipe-stats/${encodeURIComponent(canonicalId)}`, { headers });
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json() as { stat?: { success_count?: number; failure_count?: number } };
|
||||
if (!json.stat) return null;
|
||||
return {
|
||||
success_count: json.stat.success_count ?? 0,
|
||||
failure_count: json.stat.failure_count ?? 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 收集本部署 KV 全部 recipe(去重,與 GET /recipes 同邏輯),給公庫端點共用。
|
||||
async function listAllRecipes(kv: KVNamespace): Promise<RecipeDefinition[]> {
|
||||
const list = await kv.list({ prefix: 'recipe:' });
|
||||
const all = (await Promise.all(
|
||||
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
|
||||
)).filter(Boolean) as RecipeDefinition[];
|
||||
const hasUuid = new Set(all.filter(r => r.uuid).map(r => r.canonical_id));
|
||||
return all.filter(r => r.uuid || !hasUuid.has(r.canonical_id));
|
||||
}
|
||||
|
||||
// GET /public-recipes?q=&limit=&offset= — 搜尋/列出公庫 recipe。
|
||||
// 同 canonical_id 回多筆(多作者),各附市場星數,供 CC 依數據選(§7.5.5)。
|
||||
// 落空(q 無命中)→ 回 found:false + 創作引導(§7.5.6),不回空陣列乾等。
|
||||
recipesRouter.get('/public-recipes', async (c) => {
|
||||
const q = (c.req.query('q') ?? '').trim().toLowerCase();
|
||||
const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
|
||||
const offset = Number(c.req.query('offset') ?? 0);
|
||||
|
||||
const all = await listAllRecipes(c.env.RECIPES);
|
||||
const matched = q
|
||||
? all.filter(r =>
|
||||
r.canonical_id.toLowerCase().includes(q) ||
|
||||
(r.display_name ?? '').toLowerCase().includes(q) ||
|
||||
(r.description ?? '').toLowerCase().includes(q))
|
||||
: all;
|
||||
|
||||
if (q && matched.length === 0) {
|
||||
// 落空 = 創作入口(§7.5.6):讓 CC 知道「公庫沒有,可自己做一個成為作者」。
|
||||
return c.json({
|
||||
found: false,
|
||||
query: q,
|
||||
hint: `公庫無符合「${q}」的 recipe。可自行建立並 submit-p 投稿成為作者(app-store 模型)。`,
|
||||
});
|
||||
}
|
||||
|
||||
const page = matched.slice(offset, offset + limit);
|
||||
const withStats = await Promise.all(
|
||||
page.map(async r => ({
|
||||
uuid: r.uuid,
|
||||
canonical_id: r.canonical_id,
|
||||
author: r.author,
|
||||
display_name: r.display_name,
|
||||
description: r.description,
|
||||
market_stat: await fetchMarketStat(c.env, r.uuid ?? r.canonical_id), // §7.5.h per-uuid
|
||||
})),
|
||||
);
|
||||
return c.json({ found: true, recipes: withStats, count: matched.length });
|
||||
});
|
||||
|
||||
// GET /public-recipes/:canonical_id?author= — 取單一 recipe 全文(pull 用)。
|
||||
// 不指定 author → 回市場最佳版本(success_count 最高)。落空 → found:false 創作引導(§7.5.6)。
|
||||
recipesRouter.get('/public-recipes/:canonical_id', async (c) => {
|
||||
const canonicalId = c.req.param('canonical_id').trim().toLowerCase();
|
||||
const author = c.req.query('author');
|
||||
|
||||
const all = await listAllRecipes(c.env.RECIPES);
|
||||
let versions = all.filter(r => r.canonical_id === canonicalId);
|
||||
if (author) versions = versions.filter(r => r.author === author);
|
||||
|
||||
if (versions.length === 0) {
|
||||
return c.json({
|
||||
found: false,
|
||||
canonical_id: canonicalId,
|
||||
hint: `公庫無 recipe「${canonicalId}」${author ? `(author=${author})` : ''}。可自行建立並 submit-p 投稿成為作者(app-store 模型)。`,
|
||||
});
|
||||
}
|
||||
|
||||
// 多作者 → 選市場最佳(success_count 最高;無 stat 視為 0)。
|
||||
// §7.5.h:星數 per-uuid(5.1 記 uuid)→ 能真正區分 Leo 版/John 版。舊資料無 uuid fallback canonical_id。
|
||||
let best = versions[0];
|
||||
let bestStat: { success_count: number; failure_count: number } | null = null;
|
||||
let bestScore = -1;
|
||||
for (const v of versions) {
|
||||
const stat = await fetchMarketStat(c.env, v.uuid ?? v.canonical_id);
|
||||
const score = stat?.success_count ?? 0;
|
||||
if (score > bestScore) { bestScore = score; best = v; bestStat = stat; }
|
||||
}
|
||||
return c.json({ found: true, recipe: best, market_stat: bestStat });
|
||||
});
|
||||
|
||||
// DELETE /recipes/:id — 刪除(依 UUID 模型清掉 recipe:{uuid} + installed + canonical 清單裡的該 uuid + 舊 key)
|
||||
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}`),
|
||||
const canonicalId = recipe.canonical_id;
|
||||
const ops: Promise<unknown>[] = [
|
||||
c.env.RECIPES.delete(`idx:${recipe.hash_id}`),
|
||||
]);
|
||||
|
||||
return c.json({ success: true, deleted: recipe.canonical_id });
|
||||
c.env.RECIPES.delete(`recipe:${canonicalId}`), // 舊 key(若存在)
|
||||
];
|
||||
if (recipe.uuid) {
|
||||
ops.push(c.env.RECIPES.delete(`recipe:${recipe.uuid}`));
|
||||
// 從 canonical 清單移除此 uuid;若清單空了連 installed 一起清
|
||||
const listRaw = await c.env.RECIPES.get(kIdxCanonical(canonicalId));
|
||||
const uuids: string[] = listRaw ? JSON.parse(listRaw) : [];
|
||||
const left = uuids.filter(u => u !== recipe.uuid);
|
||||
if (left.length > 0) {
|
||||
ops.push(c.env.RECIPES.put(kIdxCanonical(canonicalId), JSON.stringify(left)));
|
||||
// installed 若指向被刪的 uuid → 改指剩下第一個
|
||||
const installed = await c.env.RECIPES.get(kIdxInstalled(canonicalId));
|
||||
if (installed === recipe.uuid) ops.push(c.env.RECIPES.put(kIdxInstalled(canonicalId), left[0]));
|
||||
} else {
|
||||
ops.push(c.env.RECIPES.delete(kIdxCanonical(canonicalId)));
|
||||
ops.push(c.env.RECIPES.delete(kIdxInstalled(canonicalId)));
|
||||
}
|
||||
}
|
||||
await Promise.all(ops);
|
||||
return c.json({ success: true, deleted: recipe.uuid ?? canonicalId });
|
||||
});
|
||||
|
||||
/** 用 canonical_id 或 rec_hash 查 recipe */
|
||||
/**
|
||||
* 用 canonical_id / rec_hash / uuid 查 recipe(執行時的解析入口)。
|
||||
* UUID 身份模型(§7.5.5)+ 向後相容(migration 前的舊 key):
|
||||
* 1. id 是 uuid(recipe:{uuid} 直接存在)→ 直接回。
|
||||
* 2. rec_xxxxxxxx → idx:{hash} 反查 canonical_id → 再走 canonical 解析。
|
||||
* 3. canonical_id → 先查 idx:installed:{canonical_id}(本部署安裝的唯一版本)→ recipe:{uuid};
|
||||
* 查不到 fallback 舊 key recipe:{canonical_id}(種子 / migration 前資料)。
|
||||
* 執行鏈路(component-loader/auth-dispatcher/credential-injector)都經此 → 不破執行。
|
||||
*/
|
||||
export async function resolveRecipe(
|
||||
id: string,
|
||||
kv: KVNamespace,
|
||||
): Promise<RecipeDefinition | null> {
|
||||
// rec_xxxxxxxx → 先查 idx 反查 canonical_id
|
||||
// 1. 直接 uuid(pull / market 指定版本時用)
|
||||
const direct = await kv.get(`recipe:${id}`, 'json') as RecipeDefinition | null;
|
||||
if (direct && direct.uuid) return direct;
|
||||
// direct 命中但無 uuid = 舊 key recipe:{canonical_id}(migration 前)→ 仍可用,但繼續嘗試 installed 拿新版
|
||||
// (installed 優先:migration 後新版在 recipe:{uuid},舊 key 為 fallback)
|
||||
|
||||
// 2. rec_hash 反查 canonical_id
|
||||
let canonicalId = id;
|
||||
if (id.startsWith('rec_')) {
|
||||
const canonicalId = await kv.get(`idx:${id}`);
|
||||
if (!canonicalId) return null;
|
||||
return kv.get(`recipe:${canonicalId}`, 'json');
|
||||
const looked = await kv.get(`idx:${id}`);
|
||||
if (!looked) return direct; // hash 查不到,回 step1 結果(通常 null)
|
||||
canonicalId = looked;
|
||||
}
|
||||
// 直接用 canonical_id
|
||||
return kv.get(`recipe:${id}`, 'json');
|
||||
|
||||
// 3. canonical → installed uuid → recipe:{uuid};fallback 舊 key
|
||||
const installedUuid = await kv.get(kIdxInstalled(canonicalId));
|
||||
if (installedUuid) {
|
||||
const byUuid = await kv.get(`recipe:${installedUuid}`, 'json') as RecipeDefinition | null;
|
||||
if (byUuid) return byUuid;
|
||||
}
|
||||
// fallback:舊 key recipe:{canonical_id}(direct 若正是它,已在手上)
|
||||
return direct ?? (await kv.get(`recipe:${canonicalId}`, 'json'));
|
||||
}
|
||||
|
||||
// ── Auth Recipe ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user