3f2e45f5dc
封測者 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
650 lines
31 KiB
JavaScript
650 lines
31 KiB
JavaScript
// @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 是資源 id,Vectorize 是 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)。
|
||
*
|
||
* 🔴 這個聲明是「接管同名資源」的**唯一**依據,預設 false(fail-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 var(script → 名/值)。未部署的不在裡面。
|
||
*
|
||
* 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.reclaimed(Arcrun#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 是 id,Vectorize 的身分就是名字)。
|
||
//
|
||
// 為什麼連名字都收下來(本來只留 `.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;
|
||
// 只有 D1/Vectorize 在 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` 綁定的值(#106;secret_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;
|
||
}
|