refactor(shared): 「該用哪些資源」搬出 CLI——一份實作,acr 與安裝器吃同一條規則
leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
「這個實例該用哪些資源」換到安裝器就要重寫一次 ⇒ 依 rules/07-thin-shell.md 的判準
它是**能力**,而它原本住在 cli/src/lib/resource-resolver.ts ⇒ 那本身就是違規。
後果已經真的發生:acr 那條有 Arcrun#97 的修法、安裝器那條沒有,於是安裝器照名字
找、找不到就建一顆空的綁上去 ⇒「我按了更新,工作流和登入全不見了」。
規則搬到 shared/resource-rule/(零依賴 ESM,Node 與 Workers runtime 都直接跑):
· rule.mjs 規則本體+把 CF 回應讀成事實的 normalizeLive*
· cf-resource-api.mjs ResourceApi 的 CF REST 實作——**眼睛也共用**:
兩條路各自解讀 CF 回應,只要一邊看不到既有綁定就會去新建,
#97 不需要規則寫錯就能重演
· installer-entry.mjs 安裝器唯一該碰的入口 resolveInstanceResources()
不是做成 cypher 端點的理由(自舉):這條規則要在「決定怎麼裝」的當下就用得到,
而那時 cypher 可能還不存在(安裝器的工作正是把它生出來);且輸入是使用者自己帳號的
綁定狀態,不該送去平台換答案。它是純函式,用不著變成服務。
只有一份,機械看守:
· 安裝器直接 import repo archive 裡的原稿,**不需要副本**
· acr 因為 npm pack 打不進套件目錄外的檔案,帶一份逐位元組鏡射
(scripts/sync-resource-rule.mjs 產生;build/test 先跑 --check,差一位元組就紅)
——同 cli/harness/ 產生物+世代閘的既有慣例
· cli/tests/single-implementation.test.ts 掃全 repo:7 支規則函式的實作只有一處
CLI 淨 -496 行(邏輯是搬走,不是複製)。cf-api.ts 的 CfAccountClient 保留公開介面,
ResourceApi 那七個方法全部委派共用 client。
驗證:cli 58/58 綠(含新增的兩條路一致性 fixture + 三種情境),tsc --noEmit 乾淨。
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
# `shared/resource-rule` — 「這個實例該用哪些資源」的唯一一份規則
|
||||
|
||||
> leo 2026-08-12:
|
||||
> ①「如果你沒有裝,就是新的;**如果你已經有,原來叫什麼名字就繼續用下去**。」
|
||||
> ②「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
|
||||
|
||||
① 是規則本身,② 是它該住哪裡。這個目錄就是 ②。
|
||||
|
||||
---
|
||||
|
||||
## 1. 規則(三句話)
|
||||
|
||||
判準是「**這顆 worker 現在綁著誰**」,**不是**「有沒有叫這個名字的資源」。
|
||||
|
||||
1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
|
||||
2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
|
||||
3. **只要有一點說不準就整趟停手**——讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
|
||||
該更新的 worker 一顆都不在 ⇒ **什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
|
||||
|
||||
`planResources()`(不寫入,只出計畫)與 `applyResourcePlan()`(有 blocker 就拒絕執行)分兩段,
|
||||
所以「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,不是靠誰記得寫 early return。
|
||||
|
||||
---
|
||||
|
||||
## 2. 為什麼在這裡,不在 cypher-executor 的 API
|
||||
|
||||
`.claude/rules/07-thin-shell.md` 的標準答案是「能力放 API」。這一條**不走那條路**,理由是自舉:
|
||||
|
||||
| 問題 | 說明 |
|
||||
|---|---|
|
||||
| **cypher 可能還不存在** | 這條規則要在「決定怎麼裝」的當下就用得到,而安裝器的工作正是把 cypher 生出來。把規則放進 cypher = 要先有雞才能有蛋。 |
|
||||
| **輸入是使用者自己的帳號狀態** | 判斷的依據是使用者 Cloudflare 帳號上的綁定。送去平台託管的 worker 換一個答案 ⇒ ①「能不能安裝」綁在平台是否活著,②使用者的帳號拓撲交給第三方。 |
|
||||
| **它根本不需要是服務** | 這是**純函式**:唯一的 IO 由呼叫端注入(`ResourceApi`)。薄殼原則要求「能力只實作一次」,不是「能力一定要是 HTTP」。 |
|
||||
|
||||
所以形態是**一份零依賴的 ESM**——Node 18+ 與 Cloudflare Workers runtime 都能直接 import,
|
||||
不必編譯、不必連網、不必先有任何 arcrun 元件活著。
|
||||
|
||||
其他評估過的形態:**共用 npm 套件** → 要多發一個 package + token,且安裝器得先 `npm i` 才能判斷,
|
||||
自舉問題只是換個位置;**做成一顆零件** → 得用 TinyGo/AssemblyScript 重寫一次,那正是「第二份實作」。
|
||||
|
||||
---
|
||||
|
||||
## 3. 檔案
|
||||
|
||||
| 檔案 | 內容 |
|
||||
|---|---|
|
||||
| `rule.mjs` | 規則本體:`planResources` / `applyResourcePlan` / `parseWranglerRequirements` + 把 CF 回應讀成事實的 `normalizeLiveBindings` / `normalizeLiveVars` |
|
||||
| `cf-resource-api.mjs` | `ResourceApi` 的 CF REST 實作(只用 global `fetch`)。**眼睛也要共用**——見下 §5 |
|
||||
| `installer-entry.mjs` | 安裝器唯一該碰的入口:`resolveInstanceResources()` |
|
||||
| `tests/fixture-account.mjs` | 假 Cloudflare 帳號(`fetch` 替身)+三種情境 |
|
||||
| `tests/demo.mjs` | `node shared/resource-rule/tests/demo.mjs`——零依賴、零建置就能跑的示範 |
|
||||
|
||||
🔴 **零依賴是硬規則**:只准 import 同目錄的兄弟檔,不准碰 `node:*`。
|
||||
有外部依賴就會有某條路吃不到它。`cli/tests/single-implementation.test.ts` ③ 會擋。
|
||||
|
||||
---
|
||||
|
||||
## 4. 兩條路怎麼取用
|
||||
|
||||
### 安裝器 / 任何 Worker(不需要副本)
|
||||
|
||||
安裝器本來就會下載本 repo 的 archive 當部署來源(`.claude/rules/05-deploy-convention.md`
|
||||
「WASM 來源」),`shared/resource-rule/` 就在那份 archive 裡:
|
||||
|
||||
```js
|
||||
import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
|
||||
|
||||
const r = await resolveInstanceResources({
|
||||
accountId, apiToken,
|
||||
wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // toml 的「內容」,不是路徑
|
||||
mode: isUpdate ? 'update' : 'init',
|
||||
});
|
||||
|
||||
if (r.blocked) {
|
||||
// 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
|
||||
return showAndStop(r.blockers);
|
||||
}
|
||||
// r.bindings : { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
|
||||
// r.origin : { 'kv_namespace:WEBHOOKS': 'adopted' | 'created', … }
|
||||
// r.liveVars : { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } } ← #106
|
||||
```
|
||||
|
||||
**安裝器不准自己判斷要不要建資源**,也不准自己解讀 CF 的 binding 回應。只呼叫這一支。
|
||||
|
||||
### `acr` CLI(需要一份鏡射)
|
||||
|
||||
`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒ 套件裡必須自帶一份。
|
||||
`cli/src/lib/resource-rule/` 就是本目錄的**逐位元組鏡射**,由
|
||||
`node scripts/sync-resource-rule.mjs` 產生。
|
||||
|
||||
**要改規則就改這個目錄,然後重跑 sync。** 手改鏡射會被擋下:
|
||||
`npm run build` 與 `npm test` 都先跑 `sync-resource-rule.mjs --check`,
|
||||
差一個位元組就 exit 1(同 `cli/harness/` 的產生物+世代閘慣例)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 為什麼連 CF client 也共用
|
||||
|
||||
判斷一致還不夠,**看到的東西**也要一致。
|
||||
|
||||
「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的。
|
||||
兩條路各自寫一份 client,只要有一邊把 404 當錯誤、漏了 `per_page`、少認一種欄位名
|
||||
(`namespace_id` vs `id`),那一邊就會「看不到既有綁定」——
|
||||
而看不到既有綁定的下一步,依規則就是**新建**。
|
||||
|
||||
**Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。**
|
||||
所以 `cli/src/lib/cf-api.ts` 的 `CfAccountClient` 把 `ResourceApi` 那七個方法**全部委派**
|
||||
給 `cf-resource-api.mjs`,自己不留實作。
|
||||
|
||||
---
|
||||
|
||||
## 6. 驗收
|
||||
|
||||
```bash
|
||||
cd cli && npm test # 58 項,含下列三組
|
||||
node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨立跑
|
||||
```
|
||||
|
||||
| 測試 | 證的事 |
|
||||
|---|---|
|
||||
| `cli/tests/two-paths-agree.test.ts` | 同一個帳號狀態餵給 `acr` 那條與安裝器那條,**選出的 resource id 相同**、建的東西相同、停手的理由相同 |
|
||||
| `cli/tests/single-implementation.test.ts` | ①規則的 7 支函式全 repo 只有這裡有實作 ②鏡射逐位元組相同 ③共用層零依賴 |
|
||||
| `cli/tests/resource-adoption.test.ts` | #97 本身的迴歸(沿用/不多建/四種停手情境),改共用層後照樣全過 |
|
||||
|
||||
三種情境(`tests/fixture-account.mjs` 的 `SCENARIOS`):
|
||||
|
||||
- `fresh` — 沒裝過 → **正常建新的**(不能為了沿用而變成永遠不建)
|
||||
- `installed` — 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在
|
||||
- `renamed` — **資源在但名字與預期完全不同** → 仍然沿用(#97 的病根,專門驗)
|
||||
|
||||
---
|
||||
|
||||
## 7. 相關
|
||||
|
||||
- `Arcrun#97` — 「我按了更新,工作流和登入全不見了」:CLI 那條已修,本目錄是把同一條規則交給所有路徑
|
||||
- `Arcrun#106` — 重部署把 `plain_text` var(含版本標籤)洗掉:`liveVars` 就是那些標籤
|
||||
- `Arcrun#80` / `arcrun-rag#39` — 同一個「重複做 Arcrun 的工作」家族;Arcrun 是唯一編譯點的既有慣例
|
||||
- `.claude/rules/07-thin-shell.md` — 本目錄存在的依據
|
||||
@@ -0,0 +1,202 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* cf-resource-api.mjs — 規則的**眼睛與手**:對 Cloudflare 帳號的那七個動作,也只有一份。
|
||||
*
|
||||
* `rule.mjs` 是純判斷,IO 由呼叫端注入(`ResourceApi`)。本檔就是那個注入物的正貨:
|
||||
* 用 CF REST API 實作 `ResourceApi`,零依賴、只用 global `fetch`
|
||||
* ⇒ Node 18+ 與 Cloudflare Workers runtime 都能直接跑。
|
||||
*
|
||||
* 【為什麼連這層也要共用】
|
||||
* 判斷一致還不夠——**看到的東西**也要一致。
|
||||
* 「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的;
|
||||
* 如果兩條路各自寫一份 client,隨便一個差異(打錯端點、把 404 當錯誤、漏了 per_page、
|
||||
* 少認一種欄位名)都會讓其中一條路「看不到既有綁定」——而看不到既有綁定的下一步,
|
||||
* 依規則就是**新建**。Arcrun#97 的災情不需要規則寫錯,只要眼睛不一樣就會重演。
|
||||
*
|
||||
* 這裡**故意只有 `ResourceApi` 那七個方法**。verifyAccess / 查 subdomain / KV 讀寫
|
||||
* 這些跟「該用哪些資源」無關的帳號操作留在各自的呼叫端,不往共用層堆。
|
||||
*
|
||||
* 🔴 除了同目錄的 `./rule.mjs`,這支不准 import 任何東西——共用層的價值在於
|
||||
* 「整個目錄複製到哪個 runtime 都能直接跑」,多一個外部依賴就少一條路吃得到。
|
||||
*/
|
||||
|
||||
import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
/**
|
||||
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
|
||||
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
|
||||
* @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CfResourceApiOptions
|
||||
* @property {string} accountId
|
||||
* @property {string} apiToken
|
||||
* @property {typeof globalThis.fetch} [fetch]
|
||||
* 注入用(離線測試餵假帳號、或宿主要用自己的 fetch)。預設 global fetch。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 建一個打真實 Cloudflare 的 `ResourceApi`。
|
||||
*
|
||||
* @param {CfResourceApiOptions} options
|
||||
* @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }}
|
||||
*/
|
||||
export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) {
|
||||
const doFetch = fetchImpl ?? globalThis.fetch;
|
||||
if (typeof doFetch !== 'function') {
|
||||
throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。');
|
||||
}
|
||||
const accountBase = `${CF_API_BASE}/accounts/${accountId}`;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
/**
|
||||
* 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
|
||||
*/
|
||||
async function cfRaw(path, init) {
|
||||
const res = await doFetch(`${accountBase}${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || !data?.success) {
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error:
|
||||
(data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') ||
|
||||
`HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, status: res.status, result: data.result };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function cf(path, init) {
|
||||
const { ok, status, result, error } = await cfRaw(path, init);
|
||||
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
cfRaw,
|
||||
|
||||
/**
|
||||
* 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。
|
||||
*
|
||||
* - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。
|
||||
* - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」——
|
||||
* 把查不到當成不存在,就是 #97 的根因。
|
||||
*
|
||||
* @param {string} script
|
||||
* @returns {Promise<ScriptBindings>}
|
||||
*/
|
||||
async getScriptBindings(script) {
|
||||
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
|
||||
const res = await cfRaw(path);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
|
||||
throw new Error(`讀 ${script} 綁定失敗:${res.error}`);
|
||||
}
|
||||
/** @type {RawWorkerBinding[]} */
|
||||
const raw = res.result?.bindings ?? [];
|
||||
return {
|
||||
deployed: true,
|
||||
bindings: normalizeLiveBindings(raw),
|
||||
vars: normalizeLiveVars(raw),
|
||||
};
|
||||
},
|
||||
|
||||
/** @returns {Promise<Map<string, string>>} title → id */
|
||||
async listKvNamespaces() {
|
||||
/** @type {Array<{id: string, title: string}>} */
|
||||
const result = await cf('/storage/kv/namespaces?per_page=100');
|
||||
const map = new Map();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
},
|
||||
|
||||
/** @returns {Promise<Map<string, string>>} name → uuid */
|
||||
async listD1Databases() {
|
||||
/** @type {Array<{uuid: string, name: string}>} */
|
||||
const result = await cf('/d1/database?per_page=100');
|
||||
const map = new Map();
|
||||
for (const db of result) map.set(db.name, db.uuid);
|
||||
return map;
|
||||
},
|
||||
|
||||
/** @returns {Promise<string[]>} */
|
||||
async listVectorizeIndexes() {
|
||||
/** @type {Array<{name: string}>} */
|
||||
const result = await cf('/vectorize/v2/indexes');
|
||||
return (result ?? []).map((i) => i.name);
|
||||
},
|
||||
|
||||
/**
|
||||
* 無條件新建一顆 KV namespace。
|
||||
*
|
||||
* 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
|
||||
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路
|
||||
* (安裝器取的名字跟 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。
|
||||
* 要不要建一律先過 `planResources`。
|
||||
*
|
||||
* @param {string} title
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async createKvNamespace(title) {
|
||||
const result = await cf('/storage/kv/namespaces', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
return result.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。
|
||||
* @param {string} name
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async createD1Database(name) {
|
||||
const result = await cf('/d1/database', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
return result.uuid;
|
||||
},
|
||||
|
||||
/**
|
||||
* 新建 KBDB embed 用的 Vectorize index(**bge-m3 = 1024 維 / cosine**)。
|
||||
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。
|
||||
* 沒有 ensure 版本:「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async createVectorizeIndex(name) {
|
||||
const res = await cfRaw('/vectorize/v2/indexes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
config: { dimensions: 1024, metric: 'cosine' },
|
||||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||
}),
|
||||
});
|
||||
if (res.ok) return name;
|
||||
const detail = (res.error ?? '').toLowerCase();
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
|
||||
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* installer-entry.mjs — 安裝器那條路的**唯一入口**。
|
||||
*
|
||||
* 安裝器(arcrun-rag `installer/oauth-prototype/worker.js`)不必、也不准自己判斷
|
||||
* 「該建哪些資源」——它只要呼叫這一支,拿回「每個 binding 該用哪顆資源」。
|
||||
*
|
||||
* ```js
|
||||
* import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
|
||||
*
|
||||
* const r = await resolveInstanceResources({
|
||||
* accountId, apiToken,
|
||||
* wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // 字串陣列
|
||||
* mode: isUpdate ? 'update' : 'init',
|
||||
* });
|
||||
* if (r.blocked) {
|
||||
* // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
|
||||
* return showAndStop(r.blockers);
|
||||
* }
|
||||
* // r.bindings: { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
|
||||
* // r.liveVars: { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } }
|
||||
* ```
|
||||
*
|
||||
* 為什麼安裝器不需要副本:安裝器本來就會下載本 repo 的 archive 當部署來源
|
||||
* (見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
|
||||
* `shared/resource-rule/` 就在那份 archive 裡,直接 import 即可——
|
||||
* **不必再編一次、不必貼一份、也就不會有第二種答案。**
|
||||
*/
|
||||
|
||||
import { planResources, applyResourcePlan, parseWranglerRequirements, ResourcePlanBlocked } from './rule.mjs';
|
||||
import { createCloudflareResourceApi } from './cf-resource-api.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {object} ResolveOptions
|
||||
* @property {string} accountId
|
||||
* @property {string} apiToken
|
||||
* @property {string[]} wranglerTomls 各 worker 的 wrangler.toml **內容**(不是路徑)。
|
||||
* @property {'update' | 'init'} mode 這台照定義裝過了沒。
|
||||
* @property {typeof globalThis.fetch} [fetch] 注入用(測試/宿主自帶 fetch)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ResolveResult
|
||||
* @property {boolean} blocked true = 什麼都沒建、什麼都不該部署。
|
||||
* @property {string[]} blockers blocked 時的原因原文(要原樣轉給使用者)。
|
||||
* @property {Record<string, string>} bindings `${kind}:${binding}` → 資源 id/index 名。
|
||||
* @property {Record<string, 'adopted'|'created'>} origin 同上 key → 這顆是沿用還是新建。
|
||||
* @property {Record<string, Record<string, string>>} liveVars script → 現有 plain_text var(#106)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 決定這台實例每個 binding 該用哪顆資源;照規則沿用既有、只在確定沒人綁過時才新建。
|
||||
*
|
||||
* @param {ResolveOptions} options
|
||||
* @returns {Promise<ResolveResult>}
|
||||
*/
|
||||
export async function resolveInstanceResources({ accountId, apiToken, wranglerTomls, mode, fetch }) {
|
||||
const api = createCloudflareResourceApi({ accountId, apiToken, fetch });
|
||||
|
||||
/** @type {import('./rule.mjs').BindingRequirement[]} */
|
||||
const requirements = [];
|
||||
for (const toml of wranglerTomls) {
|
||||
const parsed = parseWranglerRequirements(toml);
|
||||
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
||||
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
|
||||
}
|
||||
|
||||
/** @param {string[]} blockers @returns {ResolveResult} */
|
||||
const stop = (blockers) => ({ blocked: true, blockers, bindings: {}, origin: {}, liveVars: {} });
|
||||
|
||||
if (requirements.length === 0) {
|
||||
return stop(['這批 wrangler.toml 裡讀不到任何資源綁定需求——不確定要裝什麼,停手。']);
|
||||
}
|
||||
|
||||
let plan;
|
||||
try {
|
||||
plan = await planResources(api, requirements, mode);
|
||||
} catch (e) {
|
||||
return stop([`資源解析失敗(${e instanceof Error ? e.message : String(e)})。沒有建立任何資源。`]);
|
||||
}
|
||||
if (plan.blockers.length > 0) return stop(plan.blockers);
|
||||
|
||||
/** @type {Map<string, import('./rule.mjs').ResolvedResource>} */
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await applyResourcePlan(api, plan);
|
||||
} catch (e) {
|
||||
return stop(e instanceof ResourcePlanBlocked ? e.blockers : [e instanceof Error ? e.message : String(e)]);
|
||||
}
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const bindings = {};
|
||||
/** @type {Record<string, 'adopted'|'created'>} */
|
||||
const origin = {};
|
||||
for (const [key, r] of resolved) {
|
||||
bindings[key] = r.value;
|
||||
origin[key] = r.origin;
|
||||
}
|
||||
return { blocked: false, blockers: [], bindings, origin, liveVars: Object.fromEntries(plan.liveVars) };
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
// @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} PlannedAdopt
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
* @property {string} from 從哪顆已部署的 worker 上讀到的
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @property {string} [from]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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]);
|
||||
}
|
||||
|
||||
/** @type {Map<ResourceKind, Set<string>>} */
|
||||
const existingCache = new Map();
|
||||
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
|
||||
const listExisting = async (kind) => {
|
||||
const hit = existingCache.get(kind);
|
||||
if (hit) return hit;
|
||||
/** @type {Set<string>} */
|
||||
let set;
|
||||
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
|
||||
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
|
||||
else set = new Set(await api.listVectorizeIndexes());
|
||||
existingCache.set(kind, set);
|
||||
return set;
|
||||
};
|
||||
|
||||
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 {Set<string>} */
|
||||
let existing;
|
||||
try {
|
||||
existing = await listExisting(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
||||
`(${msg(e)})。不確定就不動——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!existing.has(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,或全新帳號。
|
||||
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
|
||||
create.push({
|
||||
kind,
|
||||
binding,
|
||||
createName: reqs[0].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,
|
||||
});
|
||||
}
|
||||
/** @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;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* demo.mjs — 安裝器那條路的**可獨立執行**證明。
|
||||
*
|
||||
* node shared/resource-rule/tests/demo.mjs
|
||||
*
|
||||
* 這支只 import `shared/resource-rule/`,**沒有 node_modules、沒有建置步驟**——
|
||||
* 跑得起來本身就是「安裝器把 repo archive 拉下來就能直接用」這句話的證據。
|
||||
* (對照組:`acr` 那條要先 npm ci + TS 轉譯才跑得動。兩條路差在外殼,判斷是同一份。)
|
||||
*
|
||||
* 三種情境各跑一次,印出每個 binding 選到哪顆資源、以及這一趟建了幾顆。
|
||||
*/
|
||||
|
||||
import { resolveInstanceResources } from '../installer-entry.mjs';
|
||||
import { makeAccount, SCENARIOS, WORKER_NEEDS } from './fixture-account.mjs';
|
||||
|
||||
/** 用 fixture 的需求組出各 worker 的 wrangler.toml 內容。 */
|
||||
function tomls() {
|
||||
return Object.entries(WORKER_NEEDS).map(([script, need]) => {
|
||||
let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`;
|
||||
for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`;
|
||||
for (const d of need.d1) {
|
||||
t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
const order = /** @type {const} */ (['fresh', 'installed', 'renamed']);
|
||||
|
||||
console.log('安裝器那條路(只 import shared/resource-rule/,零依賴、零建置)\n');
|
||||
|
||||
for (const scenario of order) {
|
||||
const mode = scenario === 'fresh' ? 'init' : 'update';
|
||||
const account = makeAccount(scenario);
|
||||
const r = await resolveInstanceResources({
|
||||
accountId: 'acct-demo',
|
||||
apiToken: 'tok-demo',
|
||||
wranglerTomls: tomls(),
|
||||
mode,
|
||||
fetch: account.fetch,
|
||||
});
|
||||
|
||||
console.log(`── ${scenario}(mode=${mode}):${SCENARIOS[scenario].label}`);
|
||||
if (r.blocked) {
|
||||
console.log(' ⛔ 停手,一顆資源都沒建:');
|
||||
for (const b of r.blockers) console.log(` • ${b}`);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(r.bindings).sort()) {
|
||||
console.log(` ${key.padEnd(30)} → ${r.bindings[key].padEnd(26)} ${r.origin[key]}`);
|
||||
}
|
||||
console.log(
|
||||
` 本趟新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆、` +
|
||||
`Vectorize ${account.created.vectorize.length} 顆` +
|
||||
`|沿用既有版本標籤 ARCRUN_BUNDLE_VERSION=` +
|
||||
`${r.liveVars['arcrun-cypher-executor']?.ARCRUN_BUNDLE_VERSION ?? '(無,全新安裝)'}\n`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* fixture-account.mjs — 一個假的 Cloudflare 帳號,做成 **`fetch` 替身**。
|
||||
*
|
||||
* 【為什麼是 fetch 替身,不是假的 ResourceApi 物件】
|
||||
* 本票要證的是「`acr` 那條與安裝器那條,跑出來的決定必須一致」。
|
||||
* 如果兩條路各自餵一個假的 `ResourceApi`,那就只測到了 `rule.mjs` 的判斷,
|
||||
* **完全跳過了「怎麼把 CF 回應讀成事實」**——而 Arcrun#97 的重演只需要眼睛不一樣就夠了
|
||||
* (一邊把 404 當錯誤、一邊漏認 `namespace_id`…)。
|
||||
* 從 `fetch` 這一層假起,兩條路就是真的走完整條鏈:HTTP → 解析 → 判斷。
|
||||
*
|
||||
* 零依賴、純 ESM,Node 與 Workers 都能跑。
|
||||
*/
|
||||
|
||||
/** arcrun 各 worker 在 wrangler.toml 裡宣告的 KV binding 名(= 需求,不是資源名)。 */
|
||||
export const KV_BINDINGS = [
|
||||
'WEBHOOKS', 'CREDENTIALS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV',
|
||||
'ANALYTICS_KV', 'EXEC_CONTEXT', 'SUBMISSIONS_KV', 'OAUTH_KV',
|
||||
];
|
||||
|
||||
/** 這台實例上有資源綁定的四顆 worker,以及各自需要的綁定。 */
|
||||
export const WORKER_NEEDS = {
|
||||
'arcrun-cypher-executor': {
|
||||
kv: ['EXEC_CONTEXT', 'WEBHOOKS', 'CREDENTIALS_KV', 'ANALYTICS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV'],
|
||||
d1: [{ binding: 'CREDENTIALS_DB', database_name: 'arcrun-kbdb' }],
|
||||
},
|
||||
'arcrun-registry': { kv: ['SUBMISSIONS_KV', 'ANALYTICS_KV'], d1: [] },
|
||||
'arcrun-mcp': { kv: ['OAUTH_KV'], d1: [] },
|
||||
'arcrun-kbdb': { kv: [], d1: [{ binding: 'DB', database_name: 'arcrun-kbdb' }] },
|
||||
};
|
||||
|
||||
/**
|
||||
* 把 WORKER_NEEDS 攤成 `BindingRequirement[]`——兩條路都用**同一份需求**進去,
|
||||
* 才能證明差異(如果有)來自實作而不是輸入。
|
||||
* @returns {Array<{kind: 'kv_namespace'|'d1', binding: string, worker: string, createName: string}>}
|
||||
*/
|
||||
export function requirements() {
|
||||
const out = [];
|
||||
for (const [worker, need] of Object.entries(WORKER_NEEDS)) {
|
||||
for (const b of need.kv) out.push({ kind: 'kv_namespace', binding: b, worker, createName: b });
|
||||
for (const d of need.d1) {
|
||||
out.push({ kind: 'd1', binding: d.binding, worker, createName: d.database_name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」——
|
||||
* 這正是 #97 的病根所在:規則**不准**拿名字當識別。
|
||||
*
|
||||
* @typedef {'fresh' | 'installed' | 'renamed'} Scenario
|
||||
*/
|
||||
|
||||
/** @type {Record<Scenario, {label: string, deployed: boolean, titleFor: (binding: string) => string}>} */
|
||||
export const SCENARIOS = {
|
||||
fresh: {
|
||||
label: '沒裝過(全新帳號,一顆 worker 都沒有)',
|
||||
deployed: false,
|
||||
titleFor: (b) => b,
|
||||
},
|
||||
installed: {
|
||||
label: '裝過了(安裝器命名慣例 arcrun-rag-<instance>-kv-<binding>)',
|
||||
deployed: true,
|
||||
titleFor: (b) => `arcrun-rag-yuga3bse-kv-${b.toLowerCase()}`,
|
||||
},
|
||||
renamed: {
|
||||
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
|
||||
deployed: true,
|
||||
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
|
||||
titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 建一個假帳號 + 對應的 `fetch` 替身。
|
||||
*
|
||||
* @param {Scenario} scenario
|
||||
* @returns {{
|
||||
* fetch: typeof globalThis.fetch,
|
||||
* created: {kv: string[], d1: string[], vectorize: string[]},
|
||||
* userData: {workflows: string[], sessions: string[], libraries: string[]},
|
||||
* kvIdFor: (binding: string) => string | undefined,
|
||||
* d1Id: string,
|
||||
* requestLog: string[],
|
||||
* }}
|
||||
*/
|
||||
export function makeAccount(scenario) {
|
||||
const spec = SCENARIOS[scenario];
|
||||
/** title → id */
|
||||
const kv = new Map();
|
||||
/** name → uuid */
|
||||
const d1 = new Map();
|
||||
/** @type {string[]} */
|
||||
const vectorize = [];
|
||||
/** script → CF `/settings` 回應裡的 bindings[] 原始形狀 */
|
||||
const scripts = new Map();
|
||||
|
||||
const created = { kv: [], d1: [], vectorize: [] };
|
||||
const requestLog = [];
|
||||
|
||||
// 使用者的東西——驗「更新完還在不在」用。掛在資源 id 上,不是掛在名字上。
|
||||
const userData = {
|
||||
workflows: ['webhook:leo:daily-digest', 'webhook:leo:inbox-sync', 'webhook:leo:rag-ingest'],
|
||||
sessions: ['session:leo-abc123'],
|
||||
libraries: ['general', '課程', '客戶', '研究'],
|
||||
};
|
||||
|
||||
const kvIdByBinding = new Map();
|
||||
const D1_ID = 'd1id-kbdb-REAL';
|
||||
|
||||
if (spec.deployed) {
|
||||
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
|
||||
for (const b of KV_BINDINGS) {
|
||||
const id = `kvid-${b.toLowerCase()}-REAL`;
|
||||
kv.set(spec.titleFor(b), id);
|
||||
kvIdByBinding.set(b, id);
|
||||
}
|
||||
d1.set('arcrun-rag-yuga3bse-kbdb', D1_ID);
|
||||
|
||||
// 已部署的 worker 上綁著它們——**這才是規則要看的事實**
|
||||
for (const [script, need] of Object.entries(WORKER_NEEDS)) {
|
||||
const bindings = [];
|
||||
for (const b of need.kv) {
|
||||
bindings.push({ type: 'kv_namespace', name: b, namespace_id: kvIdByBinding.get(b) });
|
||||
}
|
||||
for (const d of need.d1) bindings.push({ type: 'd1', name: d.binding, id: D1_ID });
|
||||
// #106:plain_text var 也在同一份回應裡
|
||||
bindings.push({ type: 'plain_text', name: 'ARCRUN_BUNDLE_VERSION', text: '1.4.33' });
|
||||
scripts.set(script, bindings);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {unknown} result @param {number} [status] */
|
||||
const ok = (result, status = 200) =>
|
||||
new Response(JSON.stringify({ success: true, result, errors: [] }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
/** @param {string} message @param {number} status */
|
||||
const fail = (message, status) =>
|
||||
new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
/** @type {typeof globalThis.fetch} */
|
||||
// @ts-expect-error — 測試替身只實作用得到的那幾條路徑
|
||||
const fakeFetch = async (input, init) => {
|
||||
const url = new URL(typeof input === 'string' ? input : String(input));
|
||||
const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, '');
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
requestLog.push(`${method} ${path}${url.search}`);
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
|
||||
// 已部署 worker 的綁定
|
||||
const m = path.match(/^\/workers\/scripts\/([^/]+)\/settings$/);
|
||||
if (m && method === 'GET') {
|
||||
const script = decodeURIComponent(m[1]);
|
||||
if (!scripts.has(script)) return fail('workers.api.error.script_not_found', 404);
|
||||
return ok({ bindings: scripts.get(script) });
|
||||
}
|
||||
|
||||
if (path === '/storage/kv/namespaces' && method === 'GET') {
|
||||
return ok([...kv].map(([title, id]) => ({ id, title })));
|
||||
}
|
||||
if (path === '/storage/kv/namespaces' && method === 'POST') {
|
||||
const id = `kvid-NEW-${created.kv.length + 1}`;
|
||||
kv.set(body.title, id);
|
||||
created.kv.push(body.title);
|
||||
return ok({ id, title: body.title });
|
||||
}
|
||||
if (path === '/d1/database' && method === 'GET') {
|
||||
return ok([...d1].map(([name, uuid]) => ({ uuid, name })));
|
||||
}
|
||||
if (path === '/d1/database' && method === 'POST') {
|
||||
const uuid = `d1id-NEW-${created.d1.length + 1}`;
|
||||
d1.set(body.name, uuid);
|
||||
created.d1.push(body.name);
|
||||
return ok({ uuid, name: body.name });
|
||||
}
|
||||
if (path === '/vectorize/v2/indexes' && method === 'GET') {
|
||||
return ok(vectorize.map((name) => ({ name })));
|
||||
}
|
||||
if (path === '/vectorize/v2/indexes' && method === 'POST') {
|
||||
vectorize.push(body.name);
|
||||
created.vectorize.push(body.name);
|
||||
return ok({ name: body.name });
|
||||
}
|
||||
|
||||
return fail(`fixture 沒有實作這條路徑:${method} ${path}`, 501);
|
||||
};
|
||||
|
||||
return {
|
||||
fetch: fakeFetch,
|
||||
created,
|
||||
userData,
|
||||
kvIdFor: (binding) => kvIdByBinding.get(binding),
|
||||
d1Id: D1_ID,
|
||||
requestLog,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user