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:
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user