Files
Arcrun/shared/resource-rule/rule.mjs
T
claude-code 3f2e45f5dc fix(resource-rule): 上次裝到一半死掉的帳號要能再裝一次(Arcrun#123)
封測者 1.4.45 實撞:
  a namespace with this account ID and title already exists
⇒ 那個帳號從此永遠裝不起來,而錯誤訊息對用戶完全無法行動。

根因:rule.mjs 第 2c 段只問「有沒有已部署的 worker 綁著它」,
不問「這個名字在帳號上是不是已經存在」。註解裡「本來就沒有東西可丟」
漏掉一種狀態——資源已建、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。
拆除 youlin 時親眼看到的 8 顆空殼 KV 是同一個形狀。

修法:2c 在 create 之前先查同名。找到同名資源時:
  · 呼叫端聲明了 createNameIsOurs → 接回那一顆(adopt + reclaimed 標記)
  · 沒聲明 → 停手,訊息帶 RES-NAME-TAKEN 錯誤碼讓用戶回報

createNameIsOurs 是接管的唯一依據,預設 false(fail-closed)。只有名字
推導自使用者自己的身分時才准聲明——安裝器的
arcrun-rag-<slugFromEmail(email)>-kv-<binding> 合格;acr 從 toml 讀到的
裸 binding 名(WEBHOOKS)不合格,因為用戶自己也可能用那個名字。

這不是把 #97 刪掉的「照名字 ensure」搬回來:
  ① #97 找不到就新建一顆頂上去(會弄丟資料);這裡找到才沿用,
     找不到照舊新建,永遠不拿新的空資源頂替既有的
  ② 排在「已部署綁定=事實」之後,名字只在沒有任何綁定可看時才有發言權
  ③ #97 無條件相信名字;這裡要呼叫端先證明名字推導自用戶身分

順手補上假帳號的保真度:FakeCloudflare.createKvNamespace 原本不擋同名,
所以半殘帳號在測試裡看起來只是「多幾顆孤兒」,實際上是裝不起來——
少了那一行,這個 bug 測不出來。fixture-account.mjs 也把
resourcesExist 與 deployed 拆開,才表達得出這個狀態。

驗證(皆為實跑):
  node shared/resource-rule/tests/half-finished-install.mjs  → 全部通過(零依賴)
  cd cli && npm test                                          → 73/73 pass
  sync-resource-rule --check                                  → 三份副本皆與原稿一致

⚠️ 只有規則這一半。安裝器要在 manifestRequirements 聲明 createNameIsOurs
才會生效,那一半在 arcrun-rag(D85)。

Refs: inkstone/Arcrun#123
2026-08-14 08:08:37 +00:00

650 lines
31 KiB
JavaScript
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.
// @ts-check
/**
* rule.mjs — 「這個實例該用哪些資源」的**唯一一份**規則。
*
* ─────────────────────────────────────────────────────────────────────────────
* 這份檔案為什麼在這裡(`shared/`),不在 `cli/`
* ─────────────────────────────────────────────────────────────────────────────
* leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
*
* `.claude/rules/07-thin-shell.md` 的判準口訣:
* 「這段邏輯換一個介面要不要重寫?」要重寫 → 它是能力,該在共用層。
*
* 「該沿用哪幾顆資源」換到安裝器就得重寫一次 ⇒ 它是**能力**,不是薄殼的事。
* 而它原本住在 `cli/src/lib/resource-resolver.ts` ⇒ 那本身就是違規,
* 後果也真的發生了:`acr` 那條有這條規則、安裝器那條沒有,於是安裝器照名字找、
* 找不到就建新的空的 ⇒ Arcrun#97「我按了更新,工作流和登入全不見了」。
*
* ── 為什麼不是 cypher-executor 的 API 端點(薄殼原則的標準答案)────────────
* **自舉**:這條規則要在「決定怎麼裝/怎麼更新」的當下就用得到,而那個當下
* cypher 可能還不存在(安裝器的工作正是把它生出來),或正要被覆蓋。
* 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**——
* 把它送去一顆平台託管的 worker 換一個答案,等於①讓「能不能安裝」綁在平台是否活著,
* ②把使用者的帳號拓撲交給第三方。兩件都不該為了形式上的漂亮而做。
*
* 薄殼原則要求的是「能力只實作一次」,不是「能力一定要是 HTTP」。
* 這條規則是**純函式**(唯一的 IO 由呼叫端注入 `ResourceApi`),
* 所以它用不著變成服務——一份零依賴的 ESM 就能讓每條路吃到同一份判斷。
*
* ── 怎麼讓兩條路吃到「同一份」而不是各留一份 ───────────────────────────────
* 本檔是**唯一被人手維護的實作**,零依賴、不吃任何 node 內建、Workers runtime 可直接跑。
* · `acr``cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**,
* 由 `scripts/sync-resource-rule.mjs` 產生(CLI 要能單獨 npm publish
* 套件目錄外的檔案打不進 tarball,故必須有這一份)。
* `npm run build` / `npm test` 都會跑 `--check`,內容一漂就紅。
* ——同 `cli/harness/`(產生物+世代閘)的既有慣例。
* · 安裝器 / 任何 Worker:安裝器本來就會下載本 repo 的 archive(部署來源,
* 見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
* 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可,**不需要再編一次、也不留副本**。
* 用法見同目錄 README.md。
*
* ─────────────────────────────────────────────────────────────────────────────
* 規則本身(leo 的兩句話)
* ─────────────────────────────────────────────────────────────────────────────
* 「如果你沒有裝,就是新的;如果你已經有,原來叫什麼名字就繼續用下去。」
*
* 判準是「**這顆 worker 現在綁著誰**」,不是「有沒有叫這個名字的資源」:
* 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
* 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
* 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
* 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
*
* ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
* `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
* ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,
* 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
*
* 🔴 這份檔案沒有 import、也不准有。任何依賴都會讓某一條路吃不到它。
*/
/**
* 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡,
* 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。
* @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind
*/
/**
* 從已部署 worker 上讀回來的一條綁定。`value`KV/D1 是資源 idVectorize 是 index 名。
* @typedef {object} LiveBinding
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
*/
/**
* @typedef {object} ScriptBindings
* @property {boolean} deployed
* false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。
* @property {LiveBinding[]} bindings
* @property {Record<string, string>} [vars]
* 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。
*
* 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize),
* plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)——
* 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。
* **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。
*/
/**
* 規則需要的 CF 能力(收窄成介面,方便離線測試餵假帳號,也讓安裝器用自己的 fetch 實作)。
* @typedef {object} ResourceApi
* @property {(script: string) => Promise<ScriptBindings>} getScriptBindings
* @property {() => Promise<Map<string, string>>} listKvNamespaces title → id
* @property {() => Promise<Map<string, string>>} listD1Databases name → uuid
* @property {() => Promise<string[]>} listVectorizeIndexes
* @property {(title: string) => Promise<string>} createKvNamespace
* @property {(name: string) => Promise<string>} createD1Database
* @property {(name: string) => Promise<string>} createVectorizeIndex
*/
/**
* 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。
* @typedef {object} BindingRequirement
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
* @property {string} createName
* @property {boolean} [createNameIsOurs]
* 呼叫端在此**聲明**`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
* ⇒ 帳號上若已經有一顆**恰好同名**的資源,它只可能是我們上一次沒裝完留下的(Arcrun#123)。
*
* 🔴 這個聲明是「接管同名資源」的**唯一**依據,預設 falsefail-closed)。
* 只有在名字**推導得出、而且推導的輸入是使用者自己的身分**時才准聲明 true——
* 安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
* slug `SHA-256('arcrun-rag:' + email)` 取前 8 碼,同一個 email 每次算出同一組名字,
* 別人算不到、也不會不小心撞上)。
*
* ⚠️ **不准**因為「名字看起來像我們的」就聲明 true。`acr` 那條從 wrangler.toml 讀到的
* createName 是裸 binding 名(`WEBHOOKS`)或 toml 宣告的庫名(`arcrun-kbdb`)——
* 那種名字使用者自己也可能拿去用,**證明不了是我們的**,所以那條路一律不聲明。
*/
/**
* @typedef {object} PlannedAdopt
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的。`reclaimed` 時為空字串——
* **沒有任何 worker 綁著它正是接收它的前提**(Arcrun#123),不是漏填。
* @property {boolean} [reclaimed]
* true = 這顆不是從某顆 worker 的綁定讀出來的,而是「帳號上已經有一顆我們自己命名的同名資源、
* 卻沒有人綁著」⇒ 上一次沒裝完留下的,這次把它接回來用(Arcrun#123)。
* 給呼叫端做診斷/統計用;**使用者不必知道「殘骸」這個詞**,對外一律講「沿用你原本的資源」。
*/
/**
* @typedef {object} PlannedCreate
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} createName
* @property {string[]} wantedBy
* @property {string[]} alsoBind 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。
*/
/**
* @typedef {object} ResourcePlan
* @property {PlannedAdopt[]} adopt
* @property {PlannedCreate[]} create
* @property {string[]} blockers 非空 = 整趟停手。applyResourcePlan 會拒絕執行。
* @property {Map<string, Record<string, string>>} liveVars
* 每顆**已部署** worker 現在掛著的 plain_text varscript → 名/值)。未部署的不在裡面。
*
* Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡——
* 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式
* (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。
*/
/**
* @typedef {object} ResolvedResource
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* 🔴 接回上次沒裝完留下的那顆(`reclaimed`**仍然算 `adopted`**,不另開第三種值——
* 它本來就是「沿用既有資源」,而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
* (安裝器那句「沿用你原本的 N 項資源」)不會因為多一種值就悄悄漏數。
* @property {string} [from]
* @property {boolean} [reclaimed] 見 PlannedAdopt.reclaimedArcrun#123)。
*/
/**
* @typedef {object} WranglerRequirements
* @property {string} script worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。
* @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings
*/
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
/** @param {string[]} blockers */
constructor(blockers) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
/** @type {string[]} */
this.blockers = blockers;
}
}
/**
* @param {ResourceKind} kind
* @param {string} binding
* @returns {string}
*/
export function bindingKey(kind, binding) {
return `${kind}:${binding}`;
}
/** @type {Record<ResourceKind, string>} */
export const KIND_LABEL = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
/**
* @param {unknown} e
* @returns {string}
*/
function msg(e) {
return e instanceof Error ? e.message : String(e);
}
/**
* 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。
*
* @param {ResourceApi} api
* @param {readonly BindingRequirement[]} requirements
* @param {'update' | 'init'} mode
* 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。
* @returns {Promise<ResourcePlan>}
*/
export async function planResources(api, requirements, mode) {
/** @type {string[]} */
const blockers = [];
/** @type {PlannedAdopt[]} */
const adopt = [];
/** @type {PlannedCreate[]} */
const create = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
/** @type {Map<string, LiveBinding[]>} */
const live = new Map();
/** @type {Map<string, Record<string, string>>} */
const liveVars = new Map();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) {
live.set(script, res.bindings);
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
liveVars.set(script, res.vars ?? {});
}
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
/** @type {Map<string, BindingRequirement[]>} */
const byKey = new Map();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
// 帳號上現有的資源,一種只查一次。**名字 → 身分**KV/D1 是 idVectorize 的身分就是名字)。
//
// 為什麼連名字都收下來(本來只留 `.values()`):
// · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values(身分)。
// · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
// 同一份 API 回應裡兩個問題都答得出來,不必多打一次。
/** @type {Map<ResourceKind, Map<string, string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
const listExistingByName = async (kind) => {
const hit = existingCache.get(kind);
if (hit) return hit;
/** @type {Map<string, string>} */
let map;
if (kind === 'kv_namespace') map = await api.listKvNamespaces();
else if (kind === 'd1') map = await api.listD1Databases();
else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
existingCache.set(kind, map);
return map;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
/** @type {Array<{value: string, script: string}>} */
const found = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
/** @type {Map<string, string>} */
let existing;
try {
existing = await listExistingByName(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (![...existing.values()].includes(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding、全新帳號,
// **或者上一次安裝建到一半死掉**(Arcrun#123)。
//
// 🔴 原本這裡直接 `create.push()`,理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**:
// 資源已經建在帳號上、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。那個當下:
// 名字已存在 ✅ / 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
// `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
// 封測者 1.4.45 實撞;youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
//
// ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**,兩者差在三個地方:
// ① #97 是「照名字找 → **找不到就新建一顆頂上去**」;這裡是「照名字找 →
// **找到才沿用那一顆,找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
// ——會弄丟資料的是那個動作,不是這個。
// ② #97 的比對凌駕於「worker 綁著誰」之上;這裡在 2b 之後,**已部署的綁定仍然絕對優先**,
// 只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
// ③ #97 無條件相信名字;這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
// `createNameIsOurs`),沒聲明就停手。
/** @type {Map<string, string>} */
let existingByName;
try {
existingByName = await listExistingByName(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
`${msg(e)})。不確定就不建——停手。`,
);
continue;
}
const createName = reqs[0].createName;
const sameName = existingByName.get(createName);
if (sameName !== undefined) {
if (!reqs[0].createNameIsOurs) {
// 名字被佔走,而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
// #97 的反向災情(安靜地接管一顆別人的)跟正向一樣糟 ⇒ fail-closed。
// 訊息不准叫使用者自己去 Cloudflare 後台動手(#121/D88:機器做得到的事不要丟回給人)。
blockers.push(
`你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]}` +
`但沒有任何 worker 綁著它,我也無法證明那顆是這次安裝建的。` +
`直接拿來用有可能蓋掉你自己的東西,所以停手了——沒有建立或改動任何資源。` +
`請把這則訊息回報給我們(錯誤碼 RES-NAME-TAKEN/${kind}/${binding}),這需要我們處理。`,
);
continue;
}
// 名字是我們替這台實例算出來的(見 createNameIsOurs 的推導條件)⇒ 這顆只可能是
// 我們上一次沒裝完留下的。沿用它=把上次做到一半的進度接回來,**不會有任何損失**:
// · 它若是空的(最常見)→ 等同於新建一顆,只是省下 CF 那個「名字已存在」的拒絕。
// · 它若有資料(更早裝過、後來 worker 被拆掉)→ 沿用正是把使用者的東西接回來。
adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
continue;
}
create.push({
kind,
binding,
createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
}
/**
* 收斂「不同 binding 其實是同一顆資源」的情況。
*
* 判準是 **toml 自己宣告的名字**`database_name` / `index_name`),不是使用者那側的資源名——
* cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`
* 那是**我們**在宣告「這兩個綁定指向同一顆庫」,跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
*
* 沒有這一步會出兩種錯:
* ① 全新安裝時建出兩顆同名 D1,KBDB 的資料與 credential 目錄從此分家。
* ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。
*
* @param {PlannedAdopt[]} adopt
* @param {PlannedCreate[]} create
* @param {Map<string, BindingRequirement[]>} byKey
* @returns {PlannedCreate[]}
*/
function shareSameResource(adopt, create, byKey) {
/** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */
const declaredName = (kind, binding) =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
/** @type {PlannedCreate[]} */
const out = [];
/** @type {Map<string, PlannedCreate>} */
const groups = new Map();
for (const c of create) {
const groupKey = `${c.kind} ${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* 照 plan 動手:沿用的原樣帶出來,該建的才建。
* 有任何 blocker 直接丟 ResourcePlanBlocked**一顆都不建**。
*
* @param {ResourceApi} api
* @param {ResourcePlan} plan
* @returns {Promise<Map<string, ResolvedResource>>}
*/
export async function applyResourcePlan(api, plan) {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
/** @type {Map<string, ResolvedResource>} */
const out = new Map();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
...(a.reclaimed ? { reclaimed: true } : {}),
});
}
/** @type {string[]} */
const madeSoFar = [];
for (const c of plan.create) {
/** @type {string} */
let value;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`建 ${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
/**
* wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。
* @type {Record<string, ResourceKind>}
*/
export const TABLE_KIND = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。
*
* 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作,
* 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求**
* kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。
*
* 也是「零依賴」的一部分:不引 TOML parser ⇒ 安裝器 import 這支不必多裝任何東西。
*
* @param {string} toml
* @returns {WranglerRequirements}
*/
export function parseWranglerRequirements(toml) {
let script = '';
let seenTable = false;
/** @type {WranglerRequirements['bindings']} */
const bindings = [];
/** @type {ResourceKind | null} */
let kind = null;
let binding = '';
let createName = '';
const flush = () => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
// ─────────────────────────────────────────────────────────────────────────────
// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看)
// ─────────────────────────────────────────────────────────────────────────────
/**
* CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀
* (同一種資源在不同 API 版本欄位名不一,故全都收)。
*
* @typedef {object} RawWorkerBinding
* @property {string} [type]
* @property {string} [name]
* @property {string} [namespace_id]
* @property {string} [id]
* @property {string} [database_id]
* @property {string} [index_name]
* @property {string} [text] `plain_text` 綁定的值(#106secret_text 不會回值,本來就讀不到,也不該讀)。
*/
/**
* 把 CF 的 binding 陣列收斂成規則認得的三種資源。不認得的型別直接略過。
*
* 🔴 這支**刻意放在規則裡**,不留在各自的 CF client
* 「什麼才算『這顆 worker 綁著某顆資源』」是規則的一部分。
* 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來(例如一邊認 `namespace_id`、
* 另一邊只認 `id`,於是一邊看得到綁定、另一邊看不到 → 後者又去新建了)。
*
* @param {RawWorkerBinding[]} raw
* @returns {LiveBinding[]}
*/
export function normalizeLiveBindings(raw) {
/** @type {LiveBinding[]} */
const out = [];
for (const b of raw) {
if (!b?.name) continue;
if (b.type === 'kv_namespace') {
const value = b.namespace_id ?? b.id;
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
} else if (b.type === 'd1' || b.type === 'd1_database') {
const value = b.id ?? b.database_id;
if (value) out.push({ kind: 'd1', binding: b.name, value });
} else if (b.type === 'vectorize') {
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
}
}
return out;
}
/**
* 抽出已部署 worker 上的 `plain_text` var#106)。
*
* 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被搬來搬去;
* wrangler deploy 不會動 secret,它們自己會留著)。
*
* @param {RawWorkerBinding[]} raw
* @returns {Record<string, string>}
*/
export function normalizeLiveVars(raw) {
/** @type {Record<string, string>} */
const out = {};
for (const b of raw) {
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
}
return out;
}