fix(resource-rule): 帳號上資源超過一頁時,規則看到的必須是全部(Arcrun#123 續集)

三支清單方法只打 `?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 <noreply@anthropic.com>
This commit is contained in:
richblack
2026-08-14 19:52:24 +08:00
parent 43323b1a78
commit 68f042cfd0
6 changed files with 531 additions and 45 deletions
+93 -6
View File
@@ -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<any[]>}
*/
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<Map<string, string>>} 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<Map<string, string>>} 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<string[]>} */
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);
},
/**