diff --git a/.changeset/d1-name-lookup-paging.md b/.changeset/d1-name-lookup-paging.md new file mode 100644 index 0000000000..75b46b5495 --- /dev/null +++ b/.changeset/d1-name-lookup-paging.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/cloudflare": patch +--- + +Fixes `emdash migrate --d1 ` failing for every database name with "Cloudflare D1 database list total_pages is invalid". The D1 list endpoint does not return `total_pages`, so the page count is now derived from `total_count` and `per_page` when it is absent. A preview database whose name only contains the requested name (the `name` filter matches substrings) no longer fails the lookup either. diff --git a/packages/cloudflare/src/db/d1-migration-target.ts b/packages/cloudflare/src/db/d1-migration-target.ts index 8100694975..3f057143f7 100644 --- a/packages/cloudflare/src/db/d1-migration-target.ts +++ b/packages/cloudflare/src/db/d1-migration-target.ts @@ -213,8 +213,13 @@ async function databaseByName( throw new Error("Cloudflare D1 database list pagination is invalid."); } seenCount += resultInfo.count; + // `name` is a substring filter, so a page can hold other databases whose names merely + // contain it. Only an exact match is validated as a deployment target; a preview database + // named `site-db-staging` must not fail the lookup for `site-db`. matches.push( - ...envelope.result.map(metadataDatabase).filter((database) => database.name === name), + ...envelope.result + .filter((database) => isRecord(database) && database.name === name) + .map(metadataDatabase), ); page += 1; } @@ -245,8 +250,14 @@ function listResultInfo(value: unknown, expectedPage: number, resultCount: numbe perPage: listInteger(value.per_page, "per_page", 1), count: listInteger(value.count, "count", 0), totalCount: listInteger(value.total_count, "total_count", 0), - totalPages: listInteger(value.total_pages, "total_pages", 0), + totalPages: 0, }; + // The D1 database list returns `page`, `per_page`, `count` and `total_count` but no + // `total_pages`, so derive it when it is absent rather than rejecting every name lookup. + result.totalPages = + value.total_pages === undefined + ? Math.ceil(result.totalCount / result.perPage) + : listInteger(value.total_pages, "total_pages", 0); if ( result.page !== expectedPage || result.perPage > DATABASE_LIST_PAGE_SIZE || diff --git a/packages/cloudflare/tests/db/d1-migration-target.test.ts b/packages/cloudflare/tests/db/d1-migration-target.test.ts index 036099380a..d7c6871216 100644 --- a/packages/cloudflare/tests/db/d1-migration-target.test.ts +++ b/packages/cloudflare/tests/db/d1-migration-target.test.ts @@ -37,6 +37,22 @@ function listApiResponse( }); } +/** The envelope the live D1 list endpoint returns: `result_info` has no `total_pages` (#2840). */ +function liveListApiResponse( + result: unknown[], + page: number, + totalCount: number, + perPage = 100, +): Response { + return Response.json({ + success: true, + errors: [], + messages: [], + result, + result_info: { count: result.length, page, per_page: perPage, total_count: totalCount }, + }); +} + function database(uuid = DATABASE_ID, name = "site-db"): Record { return { uuid, name, version: "production" }; } @@ -92,6 +108,65 @@ describe("resolveD1MigrationTarget", () => { expect(fetch).toHaveBeenCalledTimes(2); }); + describe("with the live list envelope, which has no total_pages (#2840)", () => { + const resolveByName = (fetch: typeof globalThis.fetch) => + resolveD1MigrationTarget( + { binding: "DB" }, + { + projectRoot: "/project", + env: { CLOUDFLARE_ACCOUNT_ID: ACCOUNT_ID, CLOUDFLARE_API_TOKEN: TOKEN }, + overrides: { d1: "site-db" }, + }, + { fetch }, + ); + + it("resolves a name from a single page", async () => { + const fetch = vi.fn(async () => + liveListApiResponse([database(DATABASE_ID, "site-db")], 1, 1), + ); + + await expect(resolveByName(fetch)).resolves.toMatchObject({ databaseId: DATABASE_ID }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("walks every page derived from total_count and per_page", async () => { + const fetch = vi.fn(async (input) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + return url.searchParams.get("page") === "1" + ? liveListApiResponse([database(undefined, "site-db-preview")], 1, 2, 1) + : liveListApiResponse([database(DATABASE_ID, "site-db")], 2, 2, 1); + }); + + await expect(resolveByName(fetch)).resolves.toMatchObject({ databaseId: DATABASE_ID }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("ignores a preview database whose name only contains the requested name", async () => { + // `name` is a substring filter, so the page also lists `site-db-staging`. + const fetch = vi.fn(async () => + liveListApiResponse( + [ + { + ...database("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", "site-db-staging"), + version: "preview", + }, + database(DATABASE_ID, "site-db"), + ], + 1, + 2, + ), + ); + + await expect(resolveByName(fetch)).resolves.toMatchObject({ databaseId: DATABASE_ID }); + }); + + it("still reports a missing name", async () => { + const fetch = vi.fn(async () => liveListApiResponse([], 1, 0)); + + await expect(resolveByName(fetch)).rejects.toThrow(/No D1 database named site-db/); + }); + }); + it("rejects duplicate exact names found on different result pages", async () => { const fetch = vi.fn(async (input) => { const url = new URL(input instanceof Request ? input.url : input.toString());