From 68f042cfd041e50a98038719bdf2e17a935fda6a Mon Sep 17 00:00:00 2001 From: richblack Date: Fri, 14 Aug 2026 19:52:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(resource-rule):=20=E5=B8=B3=E8=99=9F?= =?UTF-8?q?=E4=B8=8A=E8=B3=87=E6=BA=90=E8=B6=85=E9=81=8E=E4=B8=80=E9=A0=81?= =?UTF-8?q?=E6=99=82=EF=BC=8C=E8=A6=8F=E5=89=87=E7=9C=8B=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E5=BF=85=E9=A0=88=E6=98=AF=E5=85=A8=E9=83=A8=EF=BC=88Arcrun#12?= =?UTF-8?q?3=20=E7=BA=8C=E9=9B=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三支清單方法只打 `?per_page=100`,也就是**只看第一頁**。這個洞在 #123 的修法 前後嚴重度不同,這才是它必須跟那張票一起修的理由: · 修法前:被截掉的是「worker 綁著的那顆」→ 2b 判「綁著的資源不見了」 → blocker → 停手。誣告使用者,但安全。 · 修法後:被截掉的是「同名殘骸」→ 2c 判「這個名字沒被佔走」 → 去建 → CF 回 title already exists → #123 的死路原樣回來。 ⇒ 修法把它從「叫得太大聲」變成「安靜地復發」。分開出貨等於把 #123 的災情 延後到「資源比較多的帳號」再爆。 做法:`cfListAll()` 翻到底;翻不完、或數量對不上 CF 回報的 `total_count`, 一律 throw ⇒ 變 blocker ⇒ 整趟停手(README 規則第 3 條)。 「我不知道」不准被當成「它沒有」。 三支端點的分頁行為不一樣(2026-08-14 在 geek6688 帳號實測,唯讀): /storage/kv/namespaces result_info 有 total_pages /d1/database result_info **沒有** total_pages ⇒ 不能拿它當終止條件 /vectorize/v2/indexes result_info 是 null,不分頁(分頁參數被忽略) 所以終止條件只用「三支都有或都沒有」的兩件事:result_info 在不在、total_count 對不對得上。 fixture 的清單端點同步照真 CF 的形狀分頁(三支各自不同)——假資料失真就會養出 「拿 total_pages 當終止條件」這種在 D1 上必壞的實作,而測試全綠。 新增 tests/list-pagination.mjs(在舊碼上實測會紅,且第 ③ 段直接重現 「無 blocker → 排 10 顆新建 → CF 回 title already exists」的 #123 死路)。 cli 73 項全綠、demo 與 half-finished-install 全綠。 Co-Authored-By: Claude Opus 5 --- cli/src/lib/resource-rule/cf-resource-api.mjs | 99 ++++++++- shared/resource-rule/README.md | 43 +++- shared/resource-rule/cf-resource-api.mjs | 99 ++++++++- .../resource-rule/tests/fixture-account.mjs | 112 +++++++++- .../tests/half-finished-install.mjs | 31 +-- .../resource-rule/tests/list-pagination.mjs | 192 ++++++++++++++++++ 6 files changed, 531 insertions(+), 45 deletions(-) create mode 100644 shared/resource-rule/tests/list-pagination.mjs diff --git a/cli/src/lib/resource-rule/cf-resource-api.mjs b/cli/src/lib/resource-rule/cf-resource-api.mjs index 647857f..a2f9a00 100644 --- a/cli/src/lib/resource-rule/cf-resource-api.mjs +++ b/cli/src/lib/resource-rule/cf-resource-api.mjs @@ -24,6 +24,19 @@ import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs'; const CF_API_BASE = 'https://api.cloudflare.com/client/v4'; +/** + * 清單端點每頁抓幾筆。100 是 CF 這幾支端點通用的安全上限(KV 官方上限就是 100)。 + * 這個數字**不影響正確性**——`cfListAll` 會一直翻到底;它只決定要打幾次 API。 + */ +const LIST_PER_PAGE = 100; + +/** + * 翻頁的安全上限。100 頁 × 100 筆 = 10,000 顆,遠超 CF 的帳號上限 + * (KV namespace 每帳號 1,000)⇒ 正常帳號永遠碰不到。 + * 碰到了就是 CF 那邊的行為變了,這種時候**寧可 throw 也不回一份不完整的清單**。 + */ +const LIST_MAX_PAGES = 100; + /** * @typedef {import('./rule.mjs').ResourceApi} ResourceApi * @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings @@ -57,9 +70,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI /** * 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 + * `resultInfo` = CF 回應裡的 `result_info`(不分頁的端點是 `null`),`cfListAll` 靠它翻頁。 * @param {string} path * @param {RequestInit} [init] - * @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>} + * @returns {Promise<{ok: boolean, status: number, result?: any, resultInfo?: any, error?: string}>} */ async function cfRaw(path, init) { const res = await doFetch(`${accountBase}${path}`, { @@ -76,7 +90,7 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI `HTTP ${res.status}`, }; } - return { ok: true, status: res.status, result: data.result }; + return { ok: true, status: res.status, result: data.result, resultInfo: data?.result_info ?? null }; } /** @@ -90,6 +104,75 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI return result; } + /** + * 把一支「列出帳號上有什麼」的端點**翻到底**,回傳全部項目。 + * + * 【為什麼非翻不可——這是 Arcrun#123 的續集,不是效能優化】 + * 三支清單方法原本只打 `?per_page=100`,也就是**只看第一頁**。同一個截斷, + * 在 #123 的修法前後,後果**不一樣**: + * + * | 被截掉的那顆 | 規則走到哪 | 結果 | + * |---|---|---| + * | #123 修好**前**:worker 綁著它,但它落在第二頁 | 2b 判「綁著的資源不見了」 | 產生 blocker,**停手**(過度保守,但安全) | + * | #123 修好**後**:名字落在第二頁 | 2c 判「這個名字沒被佔走」 | **去建 → CF 回 title already exists ⇒ #123 的死路原樣回來** | + * + * ⇒ 修法把這個洞從「叫得太大聲」變成「**安靜地復發**」。所以規約是: + * **看不完整就不准當作看完了**——翻不完、或翻出來的數量對不上 CF 自己回報的 + * `total_count`,一律 throw,讓 `planResources` 把它變成 blocker + * (README 規則第 3 條:說不準就整趟停手,一顆都不建)。 + * + * 【三支端點的分頁行為不一樣,這裡刻意不假設它們同款】(2026-08-14 在 geek6688 帳號實測) + * - `/storage/kv/namespaces`:真分頁,`result_info` = `{page, per_page, count, total_count, total_pages}` + * - `/d1/database`:真分頁,但 `result_info` **沒有 `total_pages`**(實測 `{page, per_page, count, total_count}`) + * ⇒ **不准拿 `total_pages` 當終止條件**,那個欄位在 D1 上是 `undefined` + * - `/vectorize/v2/indexes`:**不分頁**,`result_info` 是 `null`,帶 `page`/`per_page` 也被忽略(一次回全部) + * + * 所以終止條件只用「三支都有、或三支都沒有」的兩件事:`result_info` 在不在、`total_count` 對不對得上。 + * 對不分頁的那支,這支等於只打一次就回來(那兩個被忽略的參數實測無害); + * 而萬一 CF 哪天替它補上分頁,這支會自己跟著翻——不必等下一次災情才想起來改。 + * + * @param {string} path 不含分頁參數的端點路徑(可自帶其他 query) + * @param {string} what 出錯訊息裡怎麼稱呼它 + * @returns {Promise} + */ + async function cfListAll(path, what) { + /** @type {any[]} */ + const items = []; + for (let page = 1; page <= LIST_MAX_PAGES; page++) { + const sep = path.includes('?') ? '&' : '?'; + const res = await cfRaw(`${path}${sep}per_page=${LIST_PER_PAGE}&page=${page}`); + if (!res.ok) { + throw new Error(`列 ${what} 失敗(第 ${page} 頁):${res.error ?? `HTTP ${res.status}`}`); + } + const batch = Array.isArray(res.result) ? res.result : []; + items.push(...batch); + + const info = res.resultInfo; + // 這支端點沒有分頁(Vectorize v2)⇒ 這一趟拿到的就是全部。 + if (!info) return items; + + const total = Number(info.total_count); + if (Number.isFinite(total)) { + if (items.length >= total) return items; + // CF 說還有,卻一筆都不給 ⇒ 我們看不到全部。**不准安靜地當作看完了。** + if (batch.length === 0) { + throw new Error( + `列 ${what} 只讀到 ${items.length} 筆,但 Cloudflare 說共有 ${total} 筆,第 ${page} 頁卻是空的。` + + `看不到帳號上的全部資源就沒辦法判斷該不該新建——停手。`, + ); + } + continue; // total_count 說還有就繼續翻(不看 total_pages:D1 根本沒這個欄位) + } + + // 沒有 total_count 可對,只剩「這一頁沒裝滿 ⇒ 沒有下一頁」可用。 + if (batch.length < LIST_PER_PAGE) return items; + } + throw new Error( + `列 ${what} 翻超過 ${LIST_MAX_PAGES} 頁還沒到底(已讀 ${items.length} 筆)。` + + `這不正常,寧可停手,也不拿一份不完整的清單去判斷該不該新建資源。`, + ); + } + return { cfRaw, @@ -122,7 +205,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI /** @returns {Promise>} title → id */ async listKvNamespaces() { /** @type {Array<{id: string, title: string}>} */ - const result = await cf('/storage/kv/namespaces?per_page=100'); + // 翻到底才算數(只看第一頁會讓 Arcrun#123 安靜復發,理由見 cfListAll) + const result = await cfListAll('/storage/kv/namespaces', 'KV namespace'); const map = new Map(); for (const ns of result) map.set(ns.title, ns.id); return map; @@ -131,7 +215,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI /** @returns {Promise>} name → uuid */ async listD1Databases() { /** @type {Array<{uuid: string, name: string}>} */ - const result = await cf('/d1/database?per_page=100'); + // 翻到底才算數。D1 的 result_info **沒有 total_pages**,所以終止條件只認 total_count。 + const result = await cfListAll('/d1/database', 'D1 資料庫'); const map = new Map(); for (const db of result) map.set(db.name, db.uuid); return map; @@ -140,8 +225,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI /** @returns {Promise} */ async listVectorizeIndexes() { /** @type {Array<{name: string}>} */ - const result = await cf('/vectorize/v2/indexes'); - return (result ?? []).map((i) => i.name); + // 這支端點**目前不分頁**(`result_info` 是 null),走 cfListAll 等同只打一次; + // 但 CF 哪天替它補上分頁,這裡會自己跟著翻,不必等下一次災情才想起來改。 + const result = await cfListAll('/vectorize/v2/indexes', 'Vectorize index'); + return result.map((i) => i.name); }, /** diff --git a/shared/resource-rule/README.md b/shared/resource-rule/README.md index 96c39c5..cc70595 100644 --- a/shared/resource-rule/README.md +++ b/shared/resource-rule/README.md @@ -41,6 +41,33 @@ (會把活著的實例洗成空的);這裡是**找到才沿用、找不到才照舊新建**,而且排在 「已部署的綁定=事實」之後——名字永遠只在「確定沒有任何綁定可看」時才有發言權。 +### 1.2 上面那條的前提:**清單必須是完整的**(Arcrun#123 的續集) + +1.1 整條規則建立在一個沒被說出口的假設上:「我列出來的,就是帳號上全部的資源」。 +`cf-resource-api.mjs` 原本三支清單方法只打 `?per_page=100`——**只看第一頁**。 +CF 的 KV 上限是每帳號 1,000 顆,所以「超過一頁」不是理論狀況。 + +同一個截斷,在 1.1 修好前後**後果不一樣**,這才是它非修不可的理由: + +| 被截掉的那顆 | 規則走到哪 | 結果 | +|---|---|---| +| 1.1 修好**前**:worker 綁著它,但它落在第二頁 | 「綁著的資源不見了」 | blocker,**停手**(誣告使用者,但安全) | +| 1.1 修好**後**:同名殘骸落在第二頁 | 「這個名字沒被佔走」 | **去建 → CF 回 title already exists ⇒ #123 的死路原樣回來** | + +⇒ 1.1 把這個洞從「叫得太大聲」變成「**安靜地復發**」。 + +所以規約是:**看不完整就不准當作看完了**。`cfListAll` 會翻到底;翻不完、 +或翻出來的數量對不上 CF 自己回報的 `total_count`,一律 throw ⇒ 變成 blocker ⇒ +整趟停手(第 3 條)。**「我不知道」永遠不准被當成「它沒有」。** + +三支端點的分頁行為**不一樣**(2026-08-14 在 `geek6688` 帳號實測,別假設它們同款): + +| 端點 | `result_info` | 備註 | +|---|---|---| +| `/storage/kv/namespaces` | `{page, per_page, count, total_count, total_pages}` | 真分頁 | +| `/d1/database` | `{page, per_page, count, total_count}` | 真分頁,但**沒有 `total_pages`** ⇒ 不准拿它當終止條件 | +| `/vectorize/v2/indexes` | `null` | **不分頁**,`page`/`per_page` 被忽略,一次回全部 | + --- ## 2. 為什麼在這裡,不在 cypher-executor 的 API @@ -68,8 +95,10 @@ | `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/fixture-account.mjs` | 假 Cloudflare 帳號(`fetch` 替身)+四種情境。**清單端點照真 CF 分頁**(KV 有 `total_pages`/D1 沒有/Vectorize 不分頁),形狀是 2026-08-14 在真帳號實打抄回來的 | | `tests/demo.mjs` | `node shared/resource-rule/tests/demo.mjs`——零依賴、零建置就能跑的示範 | +| `tests/half-finished-install.mjs` | #123 的迴歸守衛:上次裝到一半死掉的帳號,回來再按一次要裝得起來(§1.1) | +| `tests/list-pagination.mjs` | #123 的**續集**:帳號上資源多到一頁裝不下時,規則看到的仍是全部(§1.2) | 🔴 **零依賴是硬規則**:只准 import 同目錄的兄弟檔,不准碰 `node:*`。 有外部依賴就會有某條路吃不到它。`cli/tests/single-implementation.test.ts` ③ 會擋。 @@ -133,8 +162,10 @@ if (r.blocked) { ## 6. 驗收 ```bash -cd cli && npm test # 58 項,含下列三組 -node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨立跑 +cd cli && npm test # 73 項,含下列三組 +node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨立跑 +node shared/resource-rule/tests/half-finished-install.mjs # #123 +node shared/resource-rule/tests/list-pagination.mjs # #123 續集(清單分頁) ``` | 測試 | 證的事 | @@ -143,11 +174,15 @@ node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨 | `cli/tests/single-implementation.test.ts` | ①規則的 7 支函式全 repo 只有這裡有實作 ②鏡射逐位元組相同 ③共用層零依賴 | | `cli/tests/resource-adoption.test.ts` | #97 本身的迴歸(沿用/不多建/四種停手情境),改共用層後照樣全過 | -三種情境(`tests/fixture-account.mjs` 的 `SCENARIOS`): +四種情境(`tests/fixture-account.mjs` 的 `SCENARIOS`): - `fresh` — 沒裝過 → **正常建新的**(不能為了沿用而變成永遠不建) - `installed` — 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在 - `renamed` — **資源在但名字與預期完全不同** → 仍然沿用(#97 的病根,專門驗) +- `half-finished` — **資源已建、worker 一顆都沒部署** → 接回殘骸(#123 的病根) + +另有一個與情境正交的旋鈕:`makeAccount(情境, { decoyKv, decoyD1 })` 會在帳號上多塞 +N 顆「別人的」資源,把我們自己那幾顆擠到第二頁以後——§1.2 的分頁測試靠它。 --- diff --git a/shared/resource-rule/cf-resource-api.mjs b/shared/resource-rule/cf-resource-api.mjs index 647857f..a2f9a00 100644 --- a/shared/resource-rule/cf-resource-api.mjs +++ b/shared/resource-rule/cf-resource-api.mjs @@ -24,6 +24,19 @@ import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs'; const CF_API_BASE = 'https://api.cloudflare.com/client/v4'; +/** + * 清單端點每頁抓幾筆。100 是 CF 這幾支端點通用的安全上限(KV 官方上限就是 100)。 + * 這個數字**不影響正確性**——`cfListAll` 會一直翻到底;它只決定要打幾次 API。 + */ +const LIST_PER_PAGE = 100; + +/** + * 翻頁的安全上限。100 頁 × 100 筆 = 10,000 顆,遠超 CF 的帳號上限 + * (KV namespace 每帳號 1,000)⇒ 正常帳號永遠碰不到。 + * 碰到了就是 CF 那邊的行為變了,這種時候**寧可 throw 也不回一份不完整的清單**。 + */ +const LIST_MAX_PAGES = 100; + /** * @typedef {import('./rule.mjs').ResourceApi} ResourceApi * @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings @@ -57,9 +70,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI /** * 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 + * `resultInfo` = CF 回應裡的 `result_info`(不分頁的端點是 `null`),`cfListAll` 靠它翻頁。 * @param {string} path * @param {RequestInit} [init] - * @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>} + * @returns {Promise<{ok: boolean, status: number, result?: any, resultInfo?: any, error?: string}>} */ async function cfRaw(path, init) { const res = await doFetch(`${accountBase}${path}`, { @@ -76,7 +90,7 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI `HTTP ${res.status}`, }; } - return { ok: true, status: res.status, result: data.result }; + return { ok: true, status: res.status, result: data.result, resultInfo: data?.result_info ?? null }; } /** @@ -90,6 +104,75 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI return result; } + /** + * 把一支「列出帳號上有什麼」的端點**翻到底**,回傳全部項目。 + * + * 【為什麼非翻不可——這是 Arcrun#123 的續集,不是效能優化】 + * 三支清單方法原本只打 `?per_page=100`,也就是**只看第一頁**。同一個截斷, + * 在 #123 的修法前後,後果**不一樣**: + * + * | 被截掉的那顆 | 規則走到哪 | 結果 | + * |---|---|---| + * | #123 修好**前**:worker 綁著它,但它落在第二頁 | 2b 判「綁著的資源不見了」 | 產生 blocker,**停手**(過度保守,但安全) | + * | #123 修好**後**:名字落在第二頁 | 2c 判「這個名字沒被佔走」 | **去建 → CF 回 title already exists ⇒ #123 的死路原樣回來** | + * + * ⇒ 修法把這個洞從「叫得太大聲」變成「**安靜地復發**」。所以規約是: + * **看不完整就不准當作看完了**——翻不完、或翻出來的數量對不上 CF 自己回報的 + * `total_count`,一律 throw,讓 `planResources` 把它變成 blocker + * (README 規則第 3 條:說不準就整趟停手,一顆都不建)。 + * + * 【三支端點的分頁行為不一樣,這裡刻意不假設它們同款】(2026-08-14 在 geek6688 帳號實測) + * - `/storage/kv/namespaces`:真分頁,`result_info` = `{page, per_page, count, total_count, total_pages}` + * - `/d1/database`:真分頁,但 `result_info` **沒有 `total_pages`**(實測 `{page, per_page, count, total_count}`) + * ⇒ **不准拿 `total_pages` 當終止條件**,那個欄位在 D1 上是 `undefined` + * - `/vectorize/v2/indexes`:**不分頁**,`result_info` 是 `null`,帶 `page`/`per_page` 也被忽略(一次回全部) + * + * 所以終止條件只用「三支都有、或三支都沒有」的兩件事:`result_info` 在不在、`total_count` 對不對得上。 + * 對不分頁的那支,這支等於只打一次就回來(那兩個被忽略的參數實測無害); + * 而萬一 CF 哪天替它補上分頁,這支會自己跟著翻——不必等下一次災情才想起來改。 + * + * @param {string} path 不含分頁參數的端點路徑(可自帶其他 query) + * @param {string} what 出錯訊息裡怎麼稱呼它 + * @returns {Promise} + */ + async function cfListAll(path, what) { + /** @type {any[]} */ + const items = []; + for (let page = 1; page <= LIST_MAX_PAGES; page++) { + const sep = path.includes('?') ? '&' : '?'; + const res = await cfRaw(`${path}${sep}per_page=${LIST_PER_PAGE}&page=${page}`); + if (!res.ok) { + throw new Error(`列 ${what} 失敗(第 ${page} 頁):${res.error ?? `HTTP ${res.status}`}`); + } + const batch = Array.isArray(res.result) ? res.result : []; + items.push(...batch); + + const info = res.resultInfo; + // 這支端點沒有分頁(Vectorize v2)⇒ 這一趟拿到的就是全部。 + if (!info) return items; + + const total = Number(info.total_count); + if (Number.isFinite(total)) { + if (items.length >= total) return items; + // CF 說還有,卻一筆都不給 ⇒ 我們看不到全部。**不准安靜地當作看完了。** + if (batch.length === 0) { + throw new Error( + `列 ${what} 只讀到 ${items.length} 筆,但 Cloudflare 說共有 ${total} 筆,第 ${page} 頁卻是空的。` + + `看不到帳號上的全部資源就沒辦法判斷該不該新建——停手。`, + ); + } + continue; // total_count 說還有就繼續翻(不看 total_pages:D1 根本沒這個欄位) + } + + // 沒有 total_count 可對,只剩「這一頁沒裝滿 ⇒ 沒有下一頁」可用。 + if (batch.length < LIST_PER_PAGE) return items; + } + throw new Error( + `列 ${what} 翻超過 ${LIST_MAX_PAGES} 頁還沒到底(已讀 ${items.length} 筆)。` + + `這不正常,寧可停手,也不拿一份不完整的清單去判斷該不該新建資源。`, + ); + } + return { cfRaw, @@ -122,7 +205,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI /** @returns {Promise>} title → id */ async listKvNamespaces() { /** @type {Array<{id: string, title: string}>} */ - const result = await cf('/storage/kv/namespaces?per_page=100'); + // 翻到底才算數(只看第一頁會讓 Arcrun#123 安靜復發,理由見 cfListAll) + const result = await cfListAll('/storage/kv/namespaces', 'KV namespace'); const map = new Map(); for (const ns of result) map.set(ns.title, ns.id); return map; @@ -131,7 +215,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI /** @returns {Promise>} name → uuid */ async listD1Databases() { /** @type {Array<{uuid: string, name: string}>} */ - const result = await cf('/d1/database?per_page=100'); + // 翻到底才算數。D1 的 result_info **沒有 total_pages**,所以終止條件只認 total_count。 + const result = await cfListAll('/d1/database', 'D1 資料庫'); const map = new Map(); for (const db of result) map.set(db.name, db.uuid); return map; @@ -140,8 +225,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI /** @returns {Promise} */ async listVectorizeIndexes() { /** @type {Array<{name: string}>} */ - const result = await cf('/vectorize/v2/indexes'); - return (result ?? []).map((i) => i.name); + // 這支端點**目前不分頁**(`result_info` 是 null),走 cfListAll 等同只打一次; + // 但 CF 哪天替它補上分頁,這裡會自己跟著翻,不必等下一次災情才想起來改。 + const result = await cfListAll('/vectorize/v2/indexes', 'Vectorize index'); + return result.map((i) => i.name); }, /** diff --git a/shared/resource-rule/tests/fixture-account.mjs b/shared/resource-rule/tests/fixture-account.mjs index 1706fa6..c647bcd 100644 --- a/shared/resource-rule/tests/fixture-account.mjs +++ b/shared/resource-rule/tests/fixture-account.mjs @@ -117,9 +117,61 @@ export function installerRequirements(claimOwnership = true, d1CreateName = `${B return out; } +/** + * 真實 CF 的行為:**同名建不出來**(KV 回 400「a namespace with this account ID and title + * already exists」,D1 回 code 7502「Database with name … already exists」——兩條都在 + * `geek6688` 帳號上實打驗過)。這一層就是封測者撞到的那道牆。 + * + * fixture 過去沒有模擬它,所以「重建一批孤兒」這個假設從來沒被戳破(Arcrun#123)。 + * + * 🔴 這支**自己會翻頁**(`per_page` 開很大)。它要是只看第一頁,就會在 + * 「帳號上資源很多」的測試裡漏認同名 ⇒ 反而把被測的 bug 蓋住。 + * + * @param {ReturnType} account + * @returns {typeof globalThis.fetch} + */ +export function cfRejectsDuplicateNames(account) { + const inner = account.fetch; + const BASE = 'https://api.cloudflare.com/client/v4/accounts/x'; + /** @param {string} path @returns {Promise} */ + const listAll = async (path) => { + const res = await inner(`${BASE}${path}?per_page=100000&page=1`, {}); + return (await res.json()).result ?? []; + }; + /** @param {string} message */ + const conflict = (message) => + new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + + /** @type {typeof globalThis.fetch} */ + // @ts-expect-error — 測試替身 + return 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(); + if (method === 'POST' && (path === '/storage/kv/namespaces' || path === '/d1/database')) { + const body = JSON.parse(String(init?.body)); + if (path === '/storage/kv/namespaces') { + const taken = (await listAll(path)).some((/** @type {{title: string}} */ n) => n.title === body.title); + if (taken) return conflict('a namespace with this account ID and title already exists'); + } else { + const taken = (await listAll(path)).some((/** @type {{name: string}} */ d) => d.name === body.name); + if (taken) return conflict(`Database with name: '${body.name}' already exists`); + } + } + return inner(input, init); + }; +} + /** * 建一個假帳號 + 對應的 `fetch` 替身。 * + * @param {object} [opts] + * @param {number} [opts.decoyKv] 帳號上另外還有幾顆「別人的」KV(排在我們的前面) + * @param {number} [opts.decoyD1] 同上,D1 + * * @param {Scenario} scenario * @returns {{ * fetch: typeof globalThis.fetch, @@ -130,8 +182,12 @@ export function installerRequirements(claimOwnership = true, d1CreateName = `${B * requestLog: string[], * }} */ -export function makeAccount(scenario) { +export function makeAccount(scenario, opts = {}) { const spec = SCENARIOS[scenario]; + // 「這個帳號上還有很多**別人的**資源」。用途:把我們自己那幾顆擠到第二頁以後, + // 驗清單有沒有翻頁。CF 的 KV 上限是每帳號 1,000 顆,>100 是真實會發生的規模。 + const decoyKv = opts.decoyKv ?? 0; + const decoyD1 = opts.decoyD1 ?? 0; /** title → id */ const kv = new Map(); /** name → uuid */ @@ -154,6 +210,11 @@ export function makeAccount(scenario) { const kvIdByBinding = new Map(); const D1_ID = 'd1id-kbdb-REAL'; + // 誘餌**先塞**,我們自己的才排在它們後面 ⇒ 只看第一頁就一定看不到我們的那幾顆。 + // (真 CF 的排序不歸我們管;這裡刻意排成「最壞情況」,因為要證的正是最壞情況下也看得到。) + for (let i = 0; i < decoyKv; i++) kv.set(`someone-elses-kv-${String(i).padStart(4, '0')}`, `kvid-decoy-${i}`); + for (let i = 0; i < decoyD1; i++) d1.set(`someone-elses-db-${String(i).padStart(4, '0')}`, `d1id-decoy-${i}`); + // 資源存不存在,與 worker 部署了沒,是**兩件事**(#123:中斷的安裝會讓前者為真、後者為假)。 if (spec.resourcesExist ?? spec.deployed) { // 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分) @@ -185,6 +246,48 @@ export function makeAccount(scenario) { status, headers: { 'Content-Type': 'application/json' }, }); + + /** + * 分頁的清單回應——**照真 Cloudflare 的形狀**,不是照我們方便的形狀。 + * + * 【這些假資料憑什麼代表得了真的 CF 回應】 + * 2026-08-14 拿 `geek6688` 帳號實打過三支端點(唯讀,只列不建),逐字抄回來的: + * + * ``` + * GET /storage/kv/namespaces?per_page=5&page=1 + * → result_info {"count":5,"page":1,"per_page":5,"total_count":9,"total_pages":2} + * GET /storage/kv/namespaces?per_page=5&page=2 + * → 4 筆,result_info {"count":4,"page":2,"per_page":5,"total_count":9,"total_pages":2} + * GET /d1/database?per_page=5&page=1 + * → result_info {"count":1,"page":1,"per_page":5,"total_count":1} ← **沒有 total_pages** + * GET /vectorize/v2/indexes?per_page=1&page=1 + * → 2 筆(分頁參數被忽略),result_info: null ← **這支不分頁** + * ``` + * + * 🔴 **三支的形狀不一樣,這裡就必須不一樣**。假資料要是三支都長成 KV 那樣, + * 就會養出「拿 `total_pages` 當終止條件」這種在 D1 上必壞的實作,而測試全綠。 + * 假資料失真=測了個假的,比沒測更糟。 + * + * @param {any[]} all 這個端點上「全部」的東西 + * @param {URLSearchParams} q 呼叫端帶來的分頁參數 + * @param {{totalPages: boolean}} shape 這支端點的 result_info 帶不帶 total_pages + */ + const okPaged = (all, q, shape) => { + const perPage = Number(q.get('per_page')) || 20; + const page = Number(q.get('page')) || 1; + const slice = all.slice((page - 1) * perPage, page * perPage); + const info = { + count: slice.length, + page, + per_page: perPage, + total_count: all.length, + ...(shape.totalPages ? { total_pages: Math.max(1, Math.ceil(all.length / perPage)) } : {}), + }; + return new Response(JSON.stringify({ success: true, result: slice, errors: [], result_info: info }), { + status: 200, + 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 }] }), { @@ -210,7 +313,8 @@ export function makeAccount(scenario) { } if (path === '/storage/kv/namespaces' && method === 'GET') { - return ok([...kv].map(([title, id]) => ({ id, title }))); + // 真分頁,result_info 帶 total_pages(實測形狀,見 okPaged) + return okPaged([...kv].map(([title, id]) => ({ id, title })), url.searchParams, { totalPages: true }); } if (path === '/storage/kv/namespaces' && method === 'POST') { const id = `kvid-NEW-${created.kv.length + 1}`; @@ -219,7 +323,8 @@ export function makeAccount(scenario) { return ok({ id, title: body.title }); } if (path === '/d1/database' && method === 'GET') { - return ok([...d1].map(([name, uuid]) => ({ uuid, name }))); + // 真分頁,但 result_info **沒有 total_pages**(實測形狀,見 okPaged) + return okPaged([...d1].map(([name, uuid]) => ({ uuid, name })), url.searchParams, { totalPages: false }); } if (path === '/d1/database' && method === 'POST') { const uuid = `d1id-NEW-${created.d1.length + 1}`; @@ -228,6 +333,7 @@ export function makeAccount(scenario) { return ok({ uuid, name: body.name }); } if (path === '/vectorize/v2/indexes' && method === 'GET') { + // 這支**不分頁**:分頁參數被忽略、`result_info` 是 null(實測,見 okPaged 檔頭那段) return ok(vectorize.map((name) => ({ name }))); } if (path === '/vectorize/v2/indexes' && method === 'POST') { diff --git a/shared/resource-rule/tests/half-finished-install.mjs b/shared/resource-rule/tests/half-finished-install.mjs index 8c11065..b682be6 100644 --- a/shared/resource-rule/tests/half-finished-install.mjs +++ b/shared/resource-rule/tests/half-finished-install.mjs @@ -19,7 +19,9 @@ import { planResources, applyResourcePlan, ResourcePlanBlocked, bindingKey } from '../rule.mjs'; import { createCloudflareResourceApi } from '../cf-resource-api.mjs'; -import { makeAccount, installerRequirements, requirements, KV_BINDINGS, BASE_NAME } from './fixture-account.mjs'; +import { + makeAccount, installerRequirements, requirements, KV_BINDINGS, BASE_NAME, cfRejectsDuplicateNames, +} from './fixture-account.mjs'; let failed = 0; /** @param {boolean} cond @param {string} what */ @@ -32,31 +34,8 @@ function section(title) { console.log(`\n━━━ ${title} ━━━`); } -/** - * 真實 CF 的行為:**同名 KV 建不出來**。這一行就是封測者撞到的那道牆—— - * fixture 過去沒有模擬它,所以「重建一批孤兒」這個當年的假設從來沒有被戳破。 - * @param {ReturnType} account - */ -function cfRejectsDuplicateNames(account) { - const inner = account.fetch; - /** @type {typeof globalThis.fetch} */ - // @ts-expect-error — 測試替身 - return async (input, init) => { - const url = new URL(typeof input === 'string' ? input : String(input)); - const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, ''); - if (path === '/storage/kv/namespaces' && (init?.method ?? 'GET').toUpperCase() === 'POST') { - const title = JSON.parse(String(init?.body)).title; - const listed = await (await inner(`https://api.cloudflare.com/client/v4/accounts/x/storage/kv/namespaces`, {})).json(); - if (listed.result.some((/** @type {{title: string}} */ n) => n.title === title)) { - return new Response(JSON.stringify({ - success: false, result: null, - errors: [{ message: 'a namespace with this account ID and title already exists' }], - }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - } - } - return inner(input, init); - }; -} +// 「真 CF 會拒絕同名」這個替身搬去 fixture-account.mjs 了(pagination.mjs 也要用同一份, +// 而且它必須自己會翻頁——只看第一頁的版本會在「帳號上資源很多」的測試裡漏認同名)。 /** @param {ReturnType} account */ const apiFor = (account, fetchImpl) => diff --git a/shared/resource-rule/tests/list-pagination.mjs b/shared/resource-rule/tests/list-pagination.mjs new file mode 100644 index 0000000..099e325 --- /dev/null +++ b/shared/resource-rule/tests/list-pagination.mjs @@ -0,0 +1,192 @@ +// @ts-check +/** + * list-pagination.mjs — 「帳號上的資源多到一頁裝不下」時的迴歸守衛。 + * + * node shared/resource-rule/tests/list-pagination.mjs + * + * 【要證的那句話】 + * 「規則看到的帳號清單,就是帳號上**真正的全部**」——不論那個帳號有多少顆資源。 + * + * 【為什麼這是 Arcrun#123 的續集,而不是一個獨立的小 bug】 + * 三支清單方法原本只打 `?per_page=100`(只看第一頁)。同一個截斷, + * 在 #123 的修法前後**後果不一樣**: + * + * · 修法**前**:被截掉的是「worker 綁著的那顆」→ 2b 判「綁著的資源不見了」 + * → 產生 blocker → **停手**。過度保守,但安全。 + * · 修法**後**:被截掉的是「同名殘骸」→ 2c 判「這個名字沒被佔走」 + * → **去建 → CF 回 title already exists → #123 的死路原樣回來**。 + * + * ⇒ #123 的修法把這個洞從「叫得太大聲」變成「**安靜地復發**」。 + * 所以它必須跟 #123 同一批修掉,否則那張票只是把災情延後到「資源比較多的帳號」。 + * + * 【假資料憑什麼代表得了真的 CF】 + * `fixture-account.mjs` 的 `okPaged` 是照 2026-08-14 在 `geek6688` 帳號**實打**的回應 + * 逐字抄回來的形狀(唯讀,只列不建)——關鍵是三支端點**形狀不一樣**: + * KV 的 `result_info` 有 `total_pages`/D1 **沒有**/Vectorize 根本 `null`(不分頁)。 + * 假資料要是三支都照 KV 抄,就會養出「拿 `total_pages` 當終止條件」這種在 D1 上必壞的 + * 實作,而測試全綠。**假資料失真=測了個假的。** + * + * 零依賴、零建置,跟 demo.mjs 一樣直接 node 跑。 + */ + +import { planResources, applyResourcePlan, ResourcePlanBlocked } from '../rule.mjs'; +import { createCloudflareResourceApi } from '../cf-resource-api.mjs'; +import { + makeAccount, installerRequirements, KV_BINDINGS, BASE_NAME, cfRejectsDuplicateNames, +} from './fixture-account.mjs'; + +let failed = 0; +/** @param {boolean} cond @param {string} what */ +function check(cond, what) { + console.log(` ${cond ? '✅' : '❌'} ${what}`); + if (!cond) failed++; +} +/** @param {string} title */ +function section(title) { + console.log(`\n━━━ ${title} ━━━`); +} + +const apiFor = (account, fetchImpl) => + createCloudflareResourceApi({ accountId: 'acct-123', apiToken: 'tok-123', fetch: fetchImpl ?? account.fetch }); + +/** 帳號上「別人的」資源顆數。250 > 100 ⇒ 我們自己那幾顆一定落在第三頁。 */ +const DECOY = 250; + +// ═══════════════════════════════════════════════════════════════════════════ +section('① 清單本身:第二頁以後的東西真的被看見了'); +// ═══════════════════════════════════════════════════════════════════════════ +{ + const account = makeAccount('installed', { decoyKv: DECOY, decoyD1: DECOY }); + const api = apiFor(account); + + const kv = await api.listKvNamespaces(); + check(kv.size === DECOY + KV_BINDINGS.length, + `KV 要讀滿 ${DECOY + KV_BINDINGS.length} 顆(實得 ${kv.size})——只看第一頁的話這裡是 100`); + // 我們自己那幾顆排在誘餌後面 ⇒ 它們在第三頁。看得到=真的翻過去了。 + const lastOne = `${BASE_NAME}-kv-${KV_BINDINGS[KV_BINDINGS.length - 1].toLowerCase()}`; + check(kv.has(lastOne), `最後一頁那顆(${lastOne})也在清單裡`); + + const d1 = await api.listD1Databases(); + check(d1.size === DECOY + 1, `D1 要讀滿 ${DECOY + 1} 顆(實得 ${d1.size})`); + check(d1.has(`${BASE_NAME}-kbdb`), '第三頁的那顆 D1 也在清單裡'); + + // 真的打了三頁,不是靠某個 per_page 開很大蒙混過去 + const kvPages = account.requestLog.filter((l) => l.startsWith('GET /storage/kv/namespaces')); + check(kvPages.length === 3, `KV 清單分三次抓(實得 ${kvPages.length} 次):\n ${kvPages.join('\n ')}`); + check(kvPages.some((l) => l.includes('page=3')), '確實有打到 page=3'); + const d1Pages = account.requestLog.filter((l) => l.startsWith('GET /d1/database')); + check(d1Pages.length === 3, `D1 清單分三次抓(實得 ${d1Pages.length} 次)`); +} + +// ═══════════════════════════════════════════════════════════════════════════ +section('② 修法「前」那一面:已裝好的實例,不准因為看不完整就誣告「你的資源不見了」'); +// ═══════════════════════════════════════════════════════════════════════════ +{ + // 使用者好好地裝著,只是帳號上東西多。2b 要拿清單確認「綁著的那顆還在」—— + // 清單被截斷 ⇒ 規則會說「這顆在你的 Cloudflare 帳號上找不到了」⇒ 好好的更新被硬擋。 + const account = makeAccount('installed', { decoyKv: DECOY, decoyD1: DECOY }); + const plan = await planResources(apiFor(account), installerRequirements(), 'update'); + + check(plan.blockers.length === 0, `不該有任何 blocker(實得 ${plan.blockers.length} 條)`); + if (plan.blockers.length) console.log(plan.blockers.map((b) => ` · ${b}`).join('\n')); + check(!plan.blockers.join('\n').includes('找不到了'), '不准出現「這顆在你的帳號上找不到了」這種誣告'); + check(plan.create.length === 0, `一顆都不該新建(實得 ${plan.create.length})`); + check(plan.adopt.length === 11, `11 個綁定全部沿用(實得 ${plan.adopt.length})`); +} + +// ═══════════════════════════════════════════════════════════════════════════ +section('③ 修法「後」那一面(安靜復發的那條):半殘帳號 + 資源很多 ⇒ 仍要接回,不准去建'); +// ═══════════════════════════════════════════════════════════════════════════ +{ + // 這一格就是本檔存在的理由: + // 殘骸在第三頁 → 清單被截斷 → 2c 判「名字沒被佔走」→ 送 POST → CF 拒絕 → #123 復發。 + // 而且是**安靜地**復發:規則自己覺得一切正常。 + const account = makeAccount('half-finished', { decoyKv: DECOY, decoyD1: DECOY }); + const api = apiFor(account, cfRejectsDuplicateNames(account)); + const plan = await planResources(api, installerRequirements(true, `${BASE_NAME}-db`), 'init'); + + check(plan.blockers.length === 0, `不該有任何 blocker(實得 ${plan.blockers.length} 條)`); + if (plan.blockers.length) console.log(plan.blockers.map((b) => ` · ${b}`).join('\n')); + check(plan.create.length === 0, `一顆都不該新建(實得 ${plan.create.length} 顆要建)`); + check(plan.adopt.length === 11, `11 個綁定全部接回來(實得 ${plan.adopt.length})`); + check(plan.adopt.every((a) => a.reclaimed === true), '每一顆都標記為「接回上次留下的」'); + + // 走完 apply:CF 那道「同名建不出來」的牆還在,這一趟不准撞上去。 + await applyResourcePlan(api, plan); + check(account.created.kv.length === 0 && account.created.d1.length === 0, + `帳號上不該多出任何資源(實得 KV ${account.created.kv.length}/D1 ${account.created.d1.length})`); +} + +// ═══════════════════════════════════════════════════════════════════════════ +section('④ 三支端點形狀不同,一支都不能壞'); +// ═══════════════════════════════════════════════════════════════════════════ +{ + const account = makeAccount('fresh'); + const api = apiFor(account); + + // Vectorize:`result_info` 是 null(不分頁)。翻頁邏輯不能因此漏東西、也不能掛掉。 + await api.createVectorizeIndex('idx-a'); + await api.createVectorizeIndex('idx-b'); + await api.createVectorizeIndex('idx-c'); + const idx = await api.listVectorizeIndexes(); + check(idx.length === 3 && idx.includes('idx-c'), `不分頁的端點照樣讀得到全部(實得 ${idx.length} 個)`); + + // D1:`result_info` **沒有 total_pages**。拿 total_pages 當終止條件的實作會在這裡爆。 + const many = makeAccount('installed', { decoyD1: DECOY }); + const d1 = await apiFor(many).listD1Databases(); + check(d1.size === DECOY + 1, `D1 沒有 total_pages 也要翻得完(實得 ${d1.size})`); + + // 空帳號:第一頁就是空的,不能誤判成「還有下一頁」而空轉 + const empty = makeAccount('fresh'); + const none = await apiFor(empty).listKvNamespaces(); + check(none.size === 0, `空帳號回 0 顆且不空轉(實得 ${none.size})`); + check(empty.requestLog.filter((l) => l.startsWith('GET /storage/kv/namespaces')).length === 1, + '空帳號只打一次清單'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +section('⑤ 看不完整時要**大聲停手**,不准安靜地當作看完了'); +// ═══════════════════════════════════════════════════════════════════════════ +{ + // CF 說共有 300 筆,卻從第二頁起一筆都不給。這種時候「回一份不完整的清單」 + // 就是災難的入口(規則會拿它去判斷該不該新建)⇒ 必須 throw ⇒ 變成 blocker ⇒ 整趟停手。 + const liar = async (input) => { + const url = new URL(String(input)); + if (!url.pathname.endsWith('/storage/kv/namespaces')) { + return new Response(JSON.stringify({ success: true, result: [], errors: [], result_info: null }), + { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + const page = Number(url.searchParams.get('page')); + const result = page === 1 ? Array.from({ length: 100 }, (_, i) => ({ id: `id-${i}`, title: `t-${i}` })) : []; + return new Response(JSON.stringify({ + success: true, result, errors: [], + result_info: { count: result.length, page, per_page: 100, total_count: 300, total_pages: 3 }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + const api = createCloudflareResourceApi({ accountId: 'a', apiToken: 't', fetch: /** @type {any} */ (liar) }); + + let threw = null; + try { + await api.listKvNamespaces(); + } catch (e) { + threw = e; + } + check(threw !== null, '讀不完整 → 要 throw,不准回一份殘缺清單'); + check(String(threw?.message ?? '').includes('300'), `訊息要說清楚少了什麼(實得:${threw?.message})`); + + // 而且這個 throw 要在規則那一層變成 blocker(fail-closed),不是讓整個安裝器炸掉 + const plan = await planResources(api, installerRequirements(), 'init'); + check(plan.blockers.length > 0, '規則要把它變成 blocker'); + // 讀不到清單的那一種(KV)**一顆都不准排新建**——「不知道」不等於「它沒有」。 + // (D1 那邊清單讀得到,照規則排新建是對的;反正整份計畫被 blocker 擋著,一顆都不會真的被建。) + check(!plan.create.some((c) => c.kind === 'kv_namespace'), '讀不到清單的那一種資源不准排新建'); + try { + await applyResourcePlan(api, plan); + check(false, 'applyResourcePlan 應該要丟 ResourcePlanBlocked,但它沒有'); + } catch (e) { + check(e instanceof ResourcePlanBlocked, 'applyResourcePlan 丟 ResourcePlanBlocked'); + } +} + +console.log(`\n${failed === 0 ? '✅ 全部通過' : `❌ ${failed} 項失敗`}`); +process.exit(failed === 0 ? 0 : 1);