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
248 lines
9.7 KiB
JavaScript
248 lines
9.7 KiB
JavaScript
// @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;
|
||
}
|
||
|
||
/** 安裝器替這台實例算出來的名字前綴(`arcrun-rag-<slugFromEmail(email)>`)。 */
|
||
export const BASE_NAME = 'arcrun-rag-yuga3bse';
|
||
|
||
/**
|
||
* 四種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」——
|
||
* 這正是 #97 的病根所在:規則**不准**拿名字當識別。
|
||
*
|
||
* `resourcesExist` 與 `deployed` **刻意拆開**:兩者不一致的那一格
|
||
* (資源在、worker 不在)就是 Arcrun#123 ——「上一次裝到一半死掉」的帳號。
|
||
* 本檔原本只有 `deployed` 一個旗標,所以那個狀態**表達不出來,也就沒被測到**。
|
||
*
|
||
* @typedef {'fresh' | 'installed' | 'renamed' | 'half-finished'} Scenario
|
||
*/
|
||
|
||
/** @type {Record<Scenario, {label: string, deployed: boolean, resourcesExist?: 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) => `${BASE_NAME}-kv-${b.toLowerCase()}`,
|
||
},
|
||
renamed: {
|
||
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
|
||
deployed: true,
|
||
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
|
||
titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`,
|
||
},
|
||
'half-finished': {
|
||
label: '上一次裝到一半死掉(KV/D1 已建在帳號上,一顆 worker 都還沒部署)— Arcrun#123',
|
||
deployed: false,
|
||
resourcesExist: true,
|
||
titleFor: (b) => `${BASE_NAME}-kv-${b.toLowerCase()}`,
|
||
},
|
||
};
|
||
|
||
/**
|
||
* 安裝器那條路的需求清單:`createName` 是**安裝器自己替這台實例算出來的**,
|
||
* 所以它有資格聲明 `createNameIsOurs`(見 rule.mjs 該欄位的推導條件)。
|
||
*
|
||
* 對照 `requirements()`(走 wrangler.toml,createName 是裸 binding 名 ⇒ **不得**聲明)。
|
||
*
|
||
* @param {boolean} [claimOwnership] 預設 true;傳 false 就是「安裝器忘了聲明」的對照組。
|
||
*/
|
||
export function installerRequirements(claimOwnership = true) {
|
||
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: `${BASE_NAME}-kv-${b.toLowerCase()}`,
|
||
...(claimOwnership ? { createNameIsOurs: true } : {}),
|
||
});
|
||
}
|
||
for (const d of need.d1) {
|
||
out.push({
|
||
kind: 'd1', binding: d.binding, worker,
|
||
createName: `${BASE_NAME}-kbdb`,
|
||
...(claimOwnership ? { createNameIsOurs: true } : {}),
|
||
});
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 建一個假帳號 + 對應的 `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';
|
||
|
||
// 資源存不存在,與 worker 部署了沒,是**兩件事**(#123:中斷的安裝會讓前者為真、後者為假)。
|
||
if (spec.resourcesExist ?? 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(`${BASE_NAME}-kbdb`, D1_ID);
|
||
}
|
||
|
||
if (spec.deployed) {
|
||
// 已部署的 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,
|
||
};
|
||
}
|