bb548b6fdf
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 乾淨。
160 lines
8.0 KiB
TypeScript
160 lines
8.0 KiB
TypeScript
/**
|
||
* 兩條路必須得出同一個答案 —— 本票的核心驗收。
|
||
*
|
||
* leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
|
||
*
|
||
* 後果已經真的發生過:`acr` 那條有 Arcrun#97 的修法、安裝器那條沒有,
|
||
* 於是安裝器照名字找、找不到就建一顆空的綁上去 ⇒ 使用者的工作流與登入狀態整片消失。
|
||
*
|
||
* 這份測試把**同一個帳號狀態**餵給兩條路:
|
||
* A. `acr` 那條:`CfAccountClient` + `resource-resolver`(CLI 真正跑的 import 鏈)
|
||
* B. 安裝器那條:只 import `shared/resource-rule/`(安裝器唯一該碰的入口)
|
||
* 然後比對它們選出的 **resource id 必須相同**。
|
||
*
|
||
* 假的是 `fetch`,不是 `ResourceApi`——所以兩條路都真的走完 HTTP → 解析 → 判斷整條鏈。
|
||
* 只測判斷會漏掉「怎麼把 CF 回應讀成事實」,而 #97 的重演只要眼睛不一樣就夠了。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
// ── A:acr 那條(CLI 真正用的東西)
|
||
import { CfAccountClient } from '../src/lib/cf-api.ts';
|
||
import { planResources, applyResourcePlan, bindingKey } from '../src/lib/resource-resolver.ts';
|
||
import type { BindingRequirement } from '../src/lib/resource-resolver.ts';
|
||
|
||
// ── B:安裝器那條(只碰 shared/)
|
||
import { resolveInstanceResources } from '../../shared/resource-rule/installer-entry.mjs';
|
||
|
||
// ── 共用 fixture
|
||
import {
|
||
makeAccount,
|
||
requirements,
|
||
SCENARIOS,
|
||
WORKER_NEEDS,
|
||
type Scenario,
|
||
} from '../../shared/resource-rule/tests/fixture-account.mjs';
|
||
|
||
const ACCOUNT = 'acct-fixture';
|
||
const TOKEN = 'tok-fixture';
|
||
|
||
/** 把 fixture 的需求組成安裝器吃的 wrangler.toml 文字(它的入口是從 toml 讀需求的)。 */
|
||
function tomlsFor(): string[] {
|
||
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;
|
||
});
|
||
}
|
||
|
||
/** A:跑 acr 那條。CfAccountClient 走 global fetch,所以這裡把它換成 fixture。 */
|
||
async function runAcrPath(scenario: Scenario, mode: 'update' | 'init') {
|
||
const account = makeAccount(scenario);
|
||
const realFetch = globalThis.fetch;
|
||
globalThis.fetch = account.fetch;
|
||
try {
|
||
const api = new CfAccountClient(ACCOUNT, TOKEN);
|
||
const plan = await planResources(api, requirements() as BindingRequirement[], mode);
|
||
if (plan.blockers.length > 0) {
|
||
return { blocked: true, blockers: plan.blockers, bindings: {} as Record<string, string>, account };
|
||
}
|
||
const resolved = await applyResourcePlan(api, plan);
|
||
const bindings: Record<string, string> = {};
|
||
for (const [k, r] of resolved) bindings[k] = r.value;
|
||
return { blocked: false, blockers: [] as string[], bindings, account };
|
||
} finally {
|
||
globalThis.fetch = realFetch;
|
||
}
|
||
}
|
||
|
||
/** B:跑安裝器那條。只用 shared/ 的入口,fetch 直接注入。 */
|
||
async function runInstallerPath(scenario: Scenario, mode: 'update' | 'init') {
|
||
const account = makeAccount(scenario);
|
||
const r = await resolveInstanceResources({
|
||
accountId: ACCOUNT,
|
||
apiToken: TOKEN,
|
||
wranglerTomls: tomlsFor(),
|
||
mode,
|
||
fetch: account.fetch,
|
||
});
|
||
return { blocked: r.blocked, blockers: r.blockers, bindings: r.bindings, account };
|
||
}
|
||
|
||
/** 把兩邊的決定印出來——PR 要貼的就是這張對照表。 */
|
||
function report(scenario: Scenario, a: Record<string, string>, b: Record<string, string>): void {
|
||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||
console.log(`\n ── ${scenario}:${SCENARIOS[scenario].label}`);
|
||
console.log(` ${'binding'.padEnd(34)} ${'acr 選的'.padEnd(24)} 安裝器選的 一致?`);
|
||
for (const k of keys) {
|
||
const same = a[k] === b[k] ? '✓' : '✗';
|
||
console.log(` ${k.padEnd(34)} ${(a[k] ?? '—').padEnd(24)} ${(b[k] ?? '—').padEnd(18)} ${same}`);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
for (const scenario of ['fresh', 'installed', 'renamed'] as const) {
|
||
const mode = scenario === 'fresh' ? 'init' : 'update';
|
||
|
||
test(`兩條路一致 — ${scenario}:${SCENARIOS[scenario].label}`, async () => {
|
||
const a = await runAcrPath(scenario, mode);
|
||
const b = await runInstallerPath(scenario, mode);
|
||
|
||
assert.equal(a.blocked, b.blocked, '一邊停手、一邊照做 = 最危險的分歧');
|
||
assert.deepEqual(a.blockers, b.blockers, '停手的理由也要一樣');
|
||
report(scenario, a.bindings, b.bindings);
|
||
assert.deepEqual(
|
||
a.bindings,
|
||
b.bindings,
|
||
`${scenario}:兩條路選出的 resource id 不同——這就是 Arcrun#97 的形狀`,
|
||
);
|
||
|
||
// 建立行為也要一致(一邊沿用、一邊新建 = 使用者的東西在其中一條路上會消失)
|
||
assert.deepEqual(a.account.created, b.account.created, '兩條路「建了什麼」必須一樣');
|
||
});
|
||
}
|
||
|
||
// ── 三種情境各自該有的行為(不只是「兩邊一樣」,還要「一樣地對」)─────────────
|
||
|
||
test('情境① 沒裝過 → 正常建新的(不能為了沿用而變成永遠不建)', async () => {
|
||
const { blocked, bindings, account } = await runInstallerPath('fresh', 'init');
|
||
assert.equal(blocked, false, '全新帳號要裝得起來');
|
||
assert.equal(account.created.kv.length, 9, `應新建 9 顆 KV,實際 ${account.created.kv.length}`);
|
||
assert.equal(account.created.d1.length, 1, `應新建 1 顆 D1,實際 ${account.created.d1.length}`);
|
||
// cypher 的 CREDENTIALS_DB 與 kbdb 的 DB 宣告同一個 database_name → 只該建一顆,兩邊共用
|
||
assert.equal(bindings['d1:CREDENTIALS_DB'], bindings['d1:DB'], '同一顆 D1 不該被建成兩顆');
|
||
console.log(`\n ① 新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆` +
|
||
`(D1 共用:CREDENTIALS_DB = DB = ${bindings['d1:DB']})`);
|
||
});
|
||
|
||
test('情境② 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在', async () => {
|
||
const { blocked, bindings, account } = await runInstallerPath('installed', 'update');
|
||
assert.equal(blocked, false);
|
||
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] }, '更新不該建出任何新資源');
|
||
|
||
// 使用者的東西掛在資源 id 上:綁定還指向原本那顆 = 東西還在
|
||
assert.equal(bindings['kv_namespace:WEBHOOKS'], account.kvIdFor('WEBHOOKS'));
|
||
assert.equal(bindings['kv_namespace:SESSIONS_KV'], account.kvIdFor('SESSIONS_KV'));
|
||
assert.equal(bindings['d1:DB'], account.d1Id);
|
||
console.log(`\n ② 沿用:WEBHOOKS → ${bindings['kv_namespace:WEBHOOKS']}` +
|
||
`(工作流 ${account.userData.workflows.length} 支還在)|` +
|
||
`SESSIONS_KV → ${bindings['kv_namespace:SESSIONS_KV']}(登入 session 還在)|` +
|
||
`DB → ${bindings['d1:DB']}(子庫 ${account.userData.libraries.length} 個還在)|新建 0 顆`);
|
||
});
|
||
|
||
test('情境③ 資源在但名字與預期完全不同 → 仍然沿用(#97 的病根,專門驗)', async () => {
|
||
const { blocked, bindings, account } = await runInstallerPath('renamed', 'update');
|
||
assert.equal(blocked, false);
|
||
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] },
|
||
'名字對不上就新建 = 正是 #97:一次更新生出 9 顆空 KV,使用者的東西從畫面上消失');
|
||
for (const b of ['WEBHOOKS', 'SESSIONS_KV', 'RECIPES', 'USERS_KV']) {
|
||
assert.equal(bindings[bindingKey('kv_namespace', b)], account.kvIdFor(b),
|
||
`${b} 沒有沿用到原本那顆`);
|
||
}
|
||
console.log(`\n ③ 名字全不同(例:WEBHOOKS 那顆實際叫 "${SCENARIOS.renamed.titleFor('WEBHOOKS')}")` +
|
||
` → 仍沿用 ${bindings['kv_namespace:WEBHOOKS']},新建 0 顆`);
|
||
});
|