Skip to content

Commit 74b0c73

Browse files
CFSQL-1196: Extend D1 local dev experience to work with Sessions API bookmarks (#8445)
CFSQL-1196: Extend D1 local dev experience to work with Sessions API bookmarks
1 parent 4978e5b commit 74b0c73

File tree

11 files changed

+236
-22
lines changed

11 files changed

+236
-22
lines changed

.changeset/fuzzy-regions-post.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"miniflare": minor
3+
---
4+
5+
D1 local developer experience supports sessions API bookmarks
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dist
2+
.wrangler
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Notes about testing with this fixture
2+
3+
- This includes tests related to the D1 read replication feature.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "d1-read-replication-app",
3+
"private": true,
4+
"scripts": {
5+
"check:type": "tsc",
6+
"start": "wrangler dev",
7+
"test:ci": "vitest run",
8+
"test:watch": "vitest",
9+
"type:tests": "tsc -p ./tests/tsconfig.json"
10+
},
11+
"devDependencies": {
12+
"@cloudflare/workers-tsconfig": "workspace:*",
13+
"@cloudflare/workers-types": "^4.20250224.0",
14+
"typescript": "catalog:default",
15+
"undici": "catalog:default",
16+
"vitest": "catalog:default",
17+
"wrangler": "workspace:*"
18+
},
19+
"volta": {
20+
"extends": "../../package.json"
21+
}
22+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// These will become default after read replication is open.
2+
type D1DatabaseWithSessionsAPI = D1Database & {
3+
// constraintOrBookmark: "first-primary" | "first-unconstrained" | string
4+
withSession(constraintOrBookmark: string): D1DatabaseSession;
5+
};
6+
7+
type D1DatabaseSession = Pick<D1Database, "prepare" | "batch"> & {
8+
getBookmark(): string;
9+
};
10+
11+
export interface Env {
12+
DB01: D1DatabaseWithSessionsAPI;
13+
}
14+
15+
export default {
16+
async fetch(request: Request, env: Env) {
17+
const url = new URL(request.url);
18+
19+
let bookmark = "first-primary";
20+
let q = "select 1;";
21+
22+
if (url.pathname === "/sql") {
23+
bookmark = url.searchParams.get("bookmark");
24+
q = url.searchParams.get("q");
25+
}
26+
27+
const session = env.DB01.withSession(bookmark);
28+
// Dummy select to get the bookmark before the main query.
29+
await session.prepare("select 1").all();
30+
const bookmarkBefore = session.getBookmark();
31+
// Now do the main query requested.
32+
const result = await session.prepare(q).all();
33+
const bookmarkAfter = session.getBookmark();
34+
35+
return Response.json({
36+
bookmarkBefore,
37+
bookmarkAfter,
38+
result,
39+
});
40+
},
41+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { resolve } from "node:path";
2+
import { afterAll, beforeAll, describe, it } from "vitest";
3+
import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived";
4+
5+
describe("d1-sessions-api - getBookmark", () => {
6+
describe("with wrangler dev", () => {
7+
let ip: string, port: number, stop: (() => Promise<unknown>) | undefined;
8+
9+
beforeAll(async () => {
10+
({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [
11+
"--port=0",
12+
"--inspector-port=0",
13+
]));
14+
});
15+
16+
afterAll(async () => {
17+
await stop?.();
18+
});
19+
20+
it("should respond with bookmarks before and after a session query", async ({
21+
expect,
22+
}) => {
23+
let response = await fetch(`http://${ip}:${port}`);
24+
let parsed = await response.json();
25+
expect(response.status).toBe(200);
26+
expect(parsed).toMatchObject({
27+
bookmarkBefore: expect.stringMatching(/\w{8}-\w{8}-\w{8}-\w{32}/),
28+
bookmarkAfter: expect.stringMatching(/\w{8}-\w{8}-\w{8}-\w{32}/),
29+
});
30+
});
31+
32+
it("should progress the bookmark after a write", async ({ expect }) => {
33+
let response = await fetch(
34+
`http://${ip}:${port}?q=${encodeURIComponent("create table if not exists users1(id text);")}`
35+
);
36+
let parsed = (await response.json()) as {
37+
bookmarkAfter: string;
38+
bookmarkBefore: string;
39+
};
40+
expect(response.status).toBe(200);
41+
expect(parsed).toMatchObject({
42+
bookmarkBefore: expect.stringMatching(/\w{8}-\w{8}-\w{8}-\w{32}/),
43+
bookmarkAfter: expect.stringMatching(/\w{8}-\w{8}-\w{8}-\w{32}/),
44+
});
45+
expect(
46+
parsed.bookmarkAfter > parsed.bookmarkBefore,
47+
`before[${parsed.bookmarkBefore}] !== after[${parsed.bookmarkAfter}]`
48+
).toEqual(true);
49+
});
50+
51+
it("should maintain the latest bookmark after many queries", async ({
52+
expect,
53+
}) => {
54+
let responses = [];
55+
56+
for (let i = 0; i < 10; i++) {
57+
const resp = await fetch(
58+
`http://${ip}:${port}?q=${encodeURIComponent(`create table if not exists users${i}(id text);`)}`
59+
);
60+
let parsed = (await resp.json()) as {
61+
bookmarkAfter: string;
62+
bookmarkBefore: string;
63+
};
64+
expect(resp.status).toBe(200);
65+
responses.push(parsed);
66+
67+
expect(
68+
parsed.bookmarkAfter > parsed.bookmarkBefore,
69+
`before[${parsed.bookmarkBefore}] !== after[${parsed.bookmarkAfter}]`
70+
).toEqual(true);
71+
}
72+
73+
const lastBookmark = responses.at(-1)?.bookmarkAfter;
74+
responses.slice(0, -1).forEach((parsed) => {
75+
expect(
76+
parsed.bookmarkAfter < lastBookmark!,
77+
`previous after[${parsed.bookmarkAfter}] !< lastBookmark[${lastBookmark}]`
78+
).toEqual(true);
79+
});
80+
});
81+
});
82+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["node"]
5+
},
6+
"include": ["**/*.ts", "../../../node-types.d.ts"]
7+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"module": "CommonJS",
5+
"lib": ["ES2020"],
6+
"types": ["@cloudflare/workers-types"],
7+
"moduleResolution": "node",
8+
"noEmit": true,
9+
"skipLibCheck": true
10+
},
11+
"include": ["**/*.ts"],
12+
"exclude": ["tests"]
13+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
name = "d1-read-replication-app"
2+
main = "src/index.ts"
3+
compatibility_date = "2025-03-11"
4+
compatibility_flags = ["nodejs_compat", "experimental", "enable_d1_with_sessions_api"]
5+
6+
[[d1_databases]]
7+
binding = "DB01" # i.e. available in your Worker on env.DB
8+
database_name = "UPDATE_THIS_FOR_REMOTE_USE"
9+
preview_database_id = "UPDATE_THIS_FOR_REMOTE_USE"
10+
database_id = "UPDATE_THIS_FOR_REMOTE_USE"

packages/miniflare/src/workers/d1/database.worker.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ interface D1FailureResponse {
6666
error: string;
6767
}
6868

69+
// See https://github.com/cloudflare/workerd/blob/bcaf44290296c71f396175c30b43cff6b570231f/src/cloudflare/internal/d1-api.ts#L68-L72
70+
const D1_SESSION_COMMIT_TOKEN_HTTP_HEADER = "x-cf-d1-session-commit-token";
71+
6972
export class D1Error extends HttpError {
7073
constructor(readonly cause: unknown) {
7174
super(500);
@@ -217,7 +220,12 @@ export class D1DatabaseObject extends MiniflareDurableObject {
217220
searchParams.get("resultsFormat")
218221
);
219222

220-
return Response.json(this.#txn(queries, resultsFormat));
223+
return Response.json(this.#txn(queries, resultsFormat), {
224+
headers: {
225+
[D1_SESSION_COMMIT_TOKEN_HTTP_HEADER]:
226+
await this.state.storage.getCurrentBookmark(),
227+
},
228+
});
221229
};
222230

223231
#isExportPragma(queries: D1Query[]): queries is D1ExportPragma {

0 commit comments

Comments
 (0)