/** * Cron index — 單一固定 key 模型(kbdb-base 8.P0 止血)。 * * 背景:原本每個 cron workflow 寫一筆 `cron-idx:{apiKey}:{name}`,scheduled() 每分鐘 * `WEBHOOKS.list({prefix:'cron-idx:'})` 一次 = 1440 list/日,單獨就爆 CF KV 免費 list 上限(1000/日)。 * * 解法(SDD §8.2):所有 cron workflow 的 cron_expr 集中存進**單一固定 key** `cron-idx:_all`。 * scheduled() 每分鐘只 `get` 一次(KV get 免費額度 100K/日,遠夠)→ list 次數歸零。 * acr push(webhooks-named POST)/ delete 時對這個 key 做 read-modify-write 維護。 * * 結構:{ [ "{apiKey}:{name}" ]: cron_expr } * key 用 `{apiKey}:{name}` 維持多租戶隔離(scheduled 觸發時拆回 apiKey/name 去讀完整 record)。 */ // KVNamespace 用全域 ambient 型別(與 types.ts 一致,不從 @cloudflare/workers-types import // 以免產生第二個不相容的 KVNamespace 型別)。 /** 單一固定索引 key — 全租戶共用一筆,scheduled() 只 get 這個 */ export const CRON_INDEX_KEY = 'cron-idx:_all'; /** 索引內容:entryKey("{apiKey}:{name}")→ cron_expr */ export type CronIndex = Record; /** 組出索引 entry 的 key(apiKey + name),含 ':' 也安全:split 時 name 用 slice 還原 */ export function cronEntryKey(apiKey: string, name: string): string { return `${apiKey}:${name}`; } /** 從 entryKey 拆回 { apiKey, name }(name 可能含 ':',取第一個 ':' 後全部為 name) */ export function parseCronEntryKey(entryKey: string): { apiKey: string; name: string } | null { const idx = entryKey.indexOf(':'); if (idx <= 0) return null; return { apiKey: entryKey.slice(0, idx), name: entryKey.slice(idx + 1) }; } /** 讀整個 cron index(單次 get,不 list) */ export async function readCronIndex(kv: KVNamespace): Promise { const raw = await kv.get(CRON_INDEX_KEY, 'text'); if (!raw) return {}; try { const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? (parsed as CronIndex) : {}; } catch { return {}; } } /** * upsert / 移除單筆 cron entry(read-modify-write 單一 key)。 * @param cronExpr - 有值=upsert;null/undefined=移除(push 改掉 cron 後清乾淨) */ export async function updateCronIndexEntry( kv: KVNamespace, apiKey: string, name: string, cronExpr: string | null | undefined, ): Promise { const index = await readCronIndex(kv); const entryKey = cronEntryKey(apiKey, name); if (cronExpr) { if (index[entryKey] === cronExpr) return; // 無變化,不浪費一次 put index[entryKey] = cronExpr; } else { if (!(entryKey in index)) return; // 本來就沒有,不浪費一次 put delete index[entryKey]; } await kv.put(CRON_INDEX_KEY, JSON.stringify(index)); }