Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/d1-name-lookup-paging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/cloudflare": patch
---

Fixes `emdash migrate --d1 <name>` 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.
15 changes: 13 additions & 2 deletions packages/cloudflare/src/db/d1-migration-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 ||
Expand Down
75 changes: 75 additions & 0 deletions packages/cloudflare/tests/db/d1-migration-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return { uuid, name, version: "production" };
}
Expand Down Expand Up @@ -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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(async (input) => {
const url = new URL(input instanceof Request ? input.url : input.toString());
Expand Down
Loading