diff --git a/docs/guide/database/d1.md b/docs/guide/database/d1.md index 91c68fe46..93722edb4 100644 --- a/docs/guide/database/d1.md +++ b/docs/guide/database/d1.md @@ -59,6 +59,59 @@ export const GET = defineHandler(async () => { The first query uses the primary; subsequent queries in that session see at least the same data. Without this option, `createDb()` uses `'first-unconstrained'`, allowing its first query to use a replica. Other query libraries and raw D1 calls need their own `withSession()` to use replicas; a bare D1 binding reads from the primary. +### Carrying bookmarks between requests {#cross-request-bookmarks} + +To make a read after a save see that save in a later request, carry a [D1 bookmark](https://developers.cloudflare.com/d1/best-practices/read-replication/#start-a-session-from-previous-context-bookmark) between requests. A bookmark allows D1 to use a replica that has caught up to at least the previously observed database state. + +Use middleware to set the default `db` session before your handlers or Pages loaders run, and return its bookmark after their queries. For a browser app, a cookie carries it through redirects and navigation: + +```ts +// middleware/01.database.ts +import { getCookie, setCookie } from 'hono/cookie'; +import { defineMiddleware } from 'void'; +import { getBookmark, setSession } from 'void/db'; + +export default defineMiddleware(async (c, next) => { + setSession(getCookie(c, 'db-bookmark') ?? 'first-unconstrained'); + await next(); + + const bookmark = getBookmark(); + if (bookmark) { + setCookie(c, 'db-bookmark', bookmark, { + httpOnly: true, + secure: new URL(c.req.url).protocol === 'https:', + sameSite: 'Lax', + path: '/', + }); + } + c.header('Cache-Control', 'private, no-store'); +}); +``` + +Your handlers and loaders keep importing `db` from `void/db`. No cookie is created automatically. This example uses a session cookie; if you add an expiry, the freshness guarantee from that bookmark ends when it expires. Parallel responses can overwrite the cookie out of order, so this simple transport does not guarantee monotonic bookmarks across concurrent requests. + +For API clients, exchange a header instead: + +```ts +// middleware/01.database.ts +import { defineMiddleware } from 'void'; +import { getBookmark, setSession } from 'void/db'; + +export default defineMiddleware(async (c, next) => { + setSession(c.req.header('X-DB-Bookmark') ?? 'first-unconstrained'); + await next(); + const bookmark = getBookmark(); + if (bookmark) { + c.header('X-DB-Bookmark', bookmark); + } + c.header('Cache-Control', 'private, no-store'); +}); +``` + +The client must save the returned header and send it on its next request. For a separate D1 binding or a framework handler outside Void's request scope, pass the bookmark to `createDb(binding, { session: bookmark })` and read the resulting bookmark with `getBookmark(client)`. + +Bookmarks constrain database queries, not cached responses. Disable ISR for pages that require this guarantee (`export const revalidate = 0` in the page's `.server.ts` file), and do not serve those requests from shared response caches. A bookmark also does not guarantee visibility of a later write from another client, such as an independent payment webhook; use a primary-first session when you need the latest primary data. + ## Alternative Query Layers Use `env.DB` from `cloudflare:workers` with another query library or the raw D1 API: diff --git a/docs/reference/api.md b/docs/reference/api.md index b5460f654..60d74ffbd 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -57,7 +57,7 @@ Import Void's app-facing APIs from these paths: | `void/schema-pg` | Re-exports [`drizzle-orm/pg-core`](https://orm.drizzle.team/docs/column-types/pg) for PostgreSQL table and column builders | | `void/schema-mysql` | Re-exports `drizzle-orm/mysql-core` for MySQL table and column builders | | `void/database-provider` | `defineDatabaseProvider` and adapter types for `void db connect --provider` | -| `void/db` | `db` (Drizzle D1, PostgreSQL, or MySQL instance for the active dialect), `createDb`, query operators (`eq`, `and`, `or`, `desc`, `like`, `inArray`, etc.) | +| `void/db` | `db` (Drizzle D1, PostgreSQL, or MySQL instance for the active dialect), `createDb`, D1 session helpers (`setSession`, `getBookmark`), query operators (`eq`, `and`, `or`, `desc`, `like`, `inArray`, etc.) | | `void/seed` | `defineSeed`, `SeedContext`, `SeedFn` for programmatic `void db seed` modules | | `void/kv` | `kv`, `createKV`, typed KV client/map types | | `void/storage` | `storage`, `createStorage` for the inferred R2 binding or a specific `R2Bucket` | diff --git a/docs/reference/api/database.md b/docs/reference/api/database.md index e6a3269aa..fc543db4e 100644 --- a/docs/reference/api/database.md +++ b/docs/reference/api/database.md @@ -39,6 +39,8 @@ const db = createDb(env.MY_OTHER_DB); Pass `{ session: 'first-primary' }` to start the D1 session with a query on the primary, keeping your app's schema and query types. Create this client inside the request that needs a fresh read. The default is `'first-unconstrained'`, which allows the first query to use a replica. See [read replication](../../guide/database/d1.md#read-replication). +The `session` option also accepts a D1 bookmark from a previous request. Use `getBookmark(client)` after queries to obtain the bookmark to return to your client. + For PostgreSQL and MySQL projects, pass a connection string. ```ts @@ -53,7 +55,7 @@ const db = createDb('postgres://user:password@host:5432/app'); // D1 function createDb( d1: D1Database, - options?: { session?: D1SessionConstraint }, + options?: { session?: D1SessionConstraint | D1SessionBookmark }, ): DrizzleD1Database; // PostgreSQL @@ -63,6 +65,24 @@ function createDb(connectionString: string): NodePgDatabase; function createDb(connectionString: string): MySql2Database; ``` +## `setSession(start)` {#setsession} + +D1 only. Set the starting bookmark or constraint for the default request's `db`. Call it in middleware before the first use of `db`; calling it after that throws. It does not change explicitly created clients or later requests. + +```ts +function setSession(start: D1SessionConstraint | D1SessionBookmark): void; +``` + +## `getBookmark(client?)` {#getbookmark} + +D1 only. Return the default request session's bookmark, or pass a client returned by `createDb()` to read that client's bookmark. Returns `null` when no bookmark is available. Reading the default bookmark does not open a database session. + +```ts +function getBookmark(client?: DrizzleD1Database): D1SessionBookmark | null; +``` + +`setSession()` and the no-argument `getBookmark()` require Void's request scope. For framework handlers outside that scope, use `createDb(binding, { session: bookmark })` and `getBookmark(client)` instead. See [carrying bookmarks between requests](../../guide/database/d1.md#cross-request-bookmarks) for middleware examples. + ## Query Operators {#query-operators} `void/db` re-exports commonly used [Drizzle operators](https://orm.drizzle.team/docs/operators) so you never need to depend on `drizzle-orm` directly: diff --git a/packages/void/package.json b/packages/void/package.json index 0b91cb170..10e87214d 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -28,6 +28,7 @@ "type": "module", "imports": { "#self/_env": "./dist/runtime/env.mjs", + "#self/runtime/db-session": "./dist/runtime/db-session.mjs", "#self/email": "./dist/runtime/email.mjs", "#self/env": "./dist/runtime/env-public.mjs", "#self/client": "./dist/runtime/client.mjs", diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index a83e85722..ad1698ebc 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -44,6 +44,7 @@ Use `void deploy --skip-migrations` when database migrations run separately; it | Server/API routing and middleware | `docs/guide/server-routing.md`, `docs/integrations/hono.md` | | Pages mode, loader/action, forms, layouts | `docs/guide/pages-routing/*.md`, `docs/guide/type-safety.md` | | Database and migrations | `docs/guide/database.md`, `docs/guide/type-safety.md` | +| D1 replicas, bookmarks, read-after-write | `docs/guide/database/d1.md`, `docs/reference/api/database.md` | | Typed fetch and end-to-end typing | `docs/guide/typed-fetch.md`, `docs/guide/type-safety.md` | | Authentication | `docs/guide/auth.md`, `docs/guide/env-vars.md` | | Cloudflare runtime bindings and config | `docs/integrations/cloudflare.md`, `docs/reference/config.md`, `docs/guide/env-vars.md` | diff --git a/packages/void/src/codegen/db-types.ts b/packages/void/src/codegen/db-types.ts index cd1006d1c..e2340ee90 100644 --- a/packages/void/src/codegen/db-types.ts +++ b/packages/void/src/codegen/db-types.ts @@ -9,7 +9,7 @@ const DIALECT_DB_TYPES = { sqlite: { type: 'DrizzleD1Database', from: 'void/_db', - arg: 'd1: D1Database, options?: { session?: D1SessionConstraint }', + arg: 'd1: D1Database, options?: { session?: D1SessionConstraint | D1SessionBookmark }', }, postgresql: { type: 'NodePgDatabase', from: 'void/_db-pg', arg: 'connectionString: string' }, mysql: { type: 'MySql2Database', from: 'void/_db-mysql', arg: 'connectionString: string' }, @@ -40,6 +40,12 @@ export function generateDbDts( ...(schema ? ['import type * as schema from "@schema";', 'type Schema = typeof schema;'] : []), `export declare const db: ${type}<${schemaType}>;`, `export declare function createDb(${arg}): ${type}<${schemaType}>;`, + ...(dialect === 'sqlite' + ? [ + 'export declare function setSession(start: D1SessionConstraint | D1SessionBookmark): void;', + `export declare function getBookmark(client?: ${type}<${schemaType}>): D1SessionBookmark | null;`, + ] + : []), `export { ${DRIZZLE_OPERATORS} } from "void/_db";`, `export type { ${type} } from "${from}";`, '', diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts index 86b364044..2ebf8e14e 100644 --- a/packages/void/src/plugin-drizzle.ts +++ b/packages/void/src/plugin-drizzle.ts @@ -22,6 +22,7 @@ import { readProjectPaths, resolveProjectPaths, type ProjectPaths } from './proj const drizzleD1Resolved = fileURLToPath(import.meta.resolve('drizzle-orm/d1')); const drizzleOrmResolved = fileURLToPath(import.meta.resolve('drizzle-orm')); const envModuleResolved = fileURLToPath(import.meta.resolve('#self/_env')); +const dbSessionResolved = fileURLToPath(import.meta.resolve('#self/runtime/db-session')); const drizzleNodePgResolved = fileURLToPath(import.meta.resolve('drizzle-orm/node-postgres')); const drizzleMysql2Resolved = fileURLToPath(import.meta.resolve('drizzle-orm/mysql2')); @@ -300,17 +301,27 @@ export function createDb(connectionString) { return ` export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)}; import { drizzle } from ${JSON.stringify(drizzleD1Resolved)}; -import { getRuntimeRequestResource, requireRuntimeBinding } from ${JSON.stringify(envModuleResolved)}; +import { getRuntimeRequestResource } from ${JSON.stringify(envModuleResolved)}; +import { createD1SessionAccess } from ${JSON.stringify(dbSessionResolved)}; ${schemaImport} const _requestResourceKey = {}; +const sessions = createD1SessionAccess(${JSON.stringify(d1BindingName)}); + +export function setSession(start) { + sessions.setSession(start); +} + +export function getBookmark(client) { + return sessions.getBookmark(client === db ? undefined : client); +} export const db = new Proxy({}, { get(_, prop) { const instance = getRuntimeRequestResource( _requestResourceKey, - () => createDb(requireRuntimeBinding(${JSON.stringify(d1BindingName)})), - () => createPrimaryDb(requireRuntimeBinding(${JSON.stringify(d1BindingName)})) + () => drizzle(sessions.getSession()${drizzleConfig}), + () => drizzle(sessions.getSession()${drizzleConfig}) ); return Reflect.get(instance, prop); }, @@ -320,9 +331,6 @@ export function createDb(d1, options) { return drizzle(d1.withSession(options?.session ?? "first-unconstrained")${drizzleConfig}); } -function createPrimaryDb(d1) { - return drizzle(d1.withSession("first-primary")${drizzleConfig}); -} `; }, }, diff --git a/packages/void/src/runtime/db-session.ts b/packages/void/src/runtime/db-session.ts new file mode 100644 index 000000000..82444bdd3 --- /dev/null +++ b/packages/void/src/runtime/db-session.ts @@ -0,0 +1,49 @@ +import { getRuntimeRequestResource, requireRuntimeBinding } from './env.ts'; + +type SessionStart = D1SessionConstraint | D1SessionBookmark; +type RequestSession = { start: SessionStart; session?: D1DatabaseSession }; + +/** @internal Shared by the published D1 client and schema-aware generated clients. */ +export function createD1SessionAccess(bindingName: string): { + getSession(): D1DatabaseSession; + setSession(start: SessionStart): void; + getBookmark(client?: object): D1SessionBookmark | null; +} { + const key = {}; + function state(): RequestSession { + return getRuntimeRequestResource(key, () => ({ start: 'first-unconstrained' })); + } + return { + getSession() { + const current = getRuntimeRequestResource( + key, + () => ({ start: 'first-unconstrained' }), + // Preserve primary-first reads for framework handlers without Void's scope. + () => ({ start: 'first-primary' }), + ); + return (current.session ??= requireRuntimeBinding(bindingName).withSession( + current.start, + )); + }, + setSession(start) { + const current = state(); + if (current.session) { + throw new Error( + 'db: setSession() must be called before the first use of db in this request.', + ); + } + current.start = start; + }, + getBookmark(client) { + if (client) { + const session = Reflect.get(client, '$client') as D1DatabaseSession | undefined; + if (typeof session?.getBookmark !== 'function') { + throw new Error('db: getBookmark(client) requires a D1 session-backed client.'); + } + return session.getBookmark(); + } + // Inspect only: middleware on a route without database queries must not open a session. + return state().session?.getBookmark() ?? null; + }, + }; +} diff --git a/packages/void/src/runtime/db.ts b/packages/void/src/runtime/db.ts index 781fc1f77..0146e1fe1 100644 --- a/packages/void/src/runtime/db.ts +++ b/packages/void/src/runtime/db.ts @@ -6,6 +6,7 @@ * This stub exists for the published npm package and type declarations. */ import { drizzle as _drizzle, type DrizzleD1Database } from 'drizzle-orm/d1'; +import { createD1SessionAccess } from './db-session.ts'; import { getRuntimeBinding, getRuntimeRequestResource, requireRuntimeBinding } from './env.ts'; // Re-export commonly used Drizzle utilities so consumers never need @@ -48,15 +49,25 @@ export type { DrizzleD1Database } from 'drizzle-orm/d1'; */ export function createDb( d1: D1Database, - options?: { session?: D1SessionConstraint }, + options?: { session?: D1SessionConstraint | D1SessionBookmark }, ): DrizzleD1Database { return _drizzle( d1.withSession(options?.session ?? 'first-unconstrained') as unknown as D1Database, ); } -function createPrimaryDb(d1: D1Database): DrizzleD1Database { - return _drizzle(d1.withSession('first-primary') as unknown as D1Database); +const sessions = createD1SessionAccess('DB'); + +/** Set the default D1 session's starting bookmark or constraint before using db. */ +export function setSession(start: D1SessionConstraint | D1SessionBookmark): void { + sessions.setSession(start); +} + +/** Read the default request session's bookmark, or that of an explicit D1 client. */ +export function getBookmark>( + client?: DrizzleD1Database, +): D1SessionBookmark | null { + return sessions.getBookmark((client as object | undefined) === db ? undefined : client); } const requestDbKey = {}; @@ -86,10 +97,16 @@ export const db: DrizzleD1Database = new Proxy({} as DrizzleD1Database, { get(_, prop) { const instance = getRuntimeRequestResource( requestDbKey, - () => createDb(requireD1()), + () => { + requireD1(); + return _drizzle(sessions.getSession() as unknown as D1Database); + }, // Framework adapters do not always enter Void's request scope. Without a // shared session, each operation starts on primary to preserve fresh reads. - () => createPrimaryDb(requireD1()), + () => { + requireD1(); + return _drizzle(sessions.getSession() as unknown as D1Database); + }, ); return Reflect.get(instance, prop); }, diff --git a/packages/void/test/integration/d1-bookmarks-playground.test.ts b/packages/void/test/integration/d1-bookmarks-playground.test.ts new file mode 100644 index 000000000..671d3c629 --- /dev/null +++ b/packages/void/test/integration/d1-bookmarks-playground.test.ts @@ -0,0 +1,306 @@ +import { execSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import spawn from 'cross-spawn'; +import { chromium } from 'playwright'; +import { describe, expect, it } from 'vite-plus/test'; +import { + createIsolatedPlayground, + killChild, + startServer, +} from '../../../../test/integration-helpers.ts'; + +const ROOT = join(import.meta.dirname, '../../../..'); + +describe('playground/d1-bookmarks', () => { + for (const mode of ['dev', 'preview'] as const) { + it(`propagates bookmarks only in the fixed variant (${mode})`, async () => { + const fixture = createIsolatedPlayground(ROOT, 'playground/d1-bookmarks'); + const browser = await chromium.launch(); + let server: Awaited> | undefined; + try { + // Exercise actual server serialization; fault injection stays in the disposable fixture. + writeFileSync( + join(fixture.root, 'routes/api/[variant].ts'), + ` +import { defineHandler } from 'void'; +import { loader, action } from '../../src/database.ts'; +function fail() { + throw new Error('D1_ERROR: test failure
', { cause: new Error('test RPC disconnect') }); +} +export const GET = defineHandler((c) => { + if (c.req.header('x-demo-test-error') === 'read') fail(); + return loader(c); +}); +export const POST = defineHandler(async (c) => { + if (c.req.header('x-demo-test-error') === 'save') fail(); + const starts = []; + if (c.req.query('experiment') === 'immediate') { + c.env = { ...c.env, DB: new Proxy(c.env.DB, { + get(binding, key) { + if (key === 'withSession') return (start) => { + starts.push(start); + if (starts.length === 2 && c.req.header('x-demo-test-error') === 'immediate-read') fail(); + return binding.withSession(start); + }; + return Reflect.get(binding, key, binding); + }, + }) }; + } + const response = await action(c); + response.headers.set('x-test-session-starts', JSON.stringify(starts)); + return response; +}); +`, + ); + execSync('vp exec void db migrate', { + cwd: fixture.root, + stdio: 'pipe', + }); + if (mode === 'preview') { + execSync('vp build', { cwd: fixture.root, stdio: 'pipe' }); + } + server = await startServer( + (port) => + spawn('vp', [mode, '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: fixture.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + }), + 60_000, + { expectBody: 'D1 Bookmark Example' }, + ); + const context = await browser.newContext(); + const page = await context.newPage(); + for (const variant of ['with-bookmark', 'without-bookmark']) { + const fixed = variant === 'with-bookmark'; + const initial = await context.request.get(`${server.base}/api/${variant}`); + expect(initial.status()).toBe(200); + expect(await initial.json()).toMatchObject({ outcome: 'idle', observed: null }); + + const write = await context.request.post(`${server.base}/api/${variant}`, { + maxRedirects: 0, + }); + expect(write.status()).toBe(303); + expect(write.headers()['cache-control']).toContain('no-store'); + expect(Boolean(write.headers()['set-cookie']?.includes('d1-demo-bookmark='))).toBe(fixed); + const read = await context.request.get(`${server.base}${write.headers().location}`); + const observation = await read.json(); + expect(observation).toMatchObject({ variant, outcome: 'fresh' }); + expect(observation.observed).toBe(observation.expected); + expect(Boolean(observation.incomingBookmark)).toBe(fixed); + expect(read.headers()['x-d1-bookmark']).toBe(observation.outgoingBookmark); + + await page.goto( + `${server.base}/${variant}?experiment=redirect&pressure=0.25&trials=20&probes=50`, + ); + await page.locator('#app[data-hydrated="true"]').waitFor(); + expect( + await page.getByRole('checkbox', { name: 'Add replication pressure' }).isChecked(), + ).toBe(true); + expect( + await page.getByRole('combobox', { name: 'Pressure per trial' }).inputValue(), + ).toBe('0.25'); + expect( + await page + .getByRole('combobox', { name: 'Pressure per trial' }) + .locator('option') + .allTextContents(), + ).toEqual(['256 KiB', '512 KiB', '1 MiB', '2 MiB']); + expect(await page.getByRole('combobox', { name: 'Reads per write' }).count()).toBe(0); + expect( + await page.getByRole('combobox', { name: 'Pressure writes per save' }).count(), + ).toBe(0); + expect(await page.getByRole('button', { name: /^Save and/ }).count()).toBe(0); + await page.getByRole('combobox', { name: 'Pressure per trial' }).selectOption('0.5'); + expect( + await page.getByRole('combobox', { name: 'Probes', exact: true }).inputValue(), + ).toBe('50'); + expect( + await page.getByRole('combobox', { name: 'Trials', exact: true }).inputValue(), + ).toBe('20'); + await expect.poll(() => new URL(page.url()).searchParams.get('pressure')).toBe('0.5'); + await page.reload(); + await page.locator('#app[data-hydrated="true"]').waitFor(); + await expect + .poll(() => page.getByRole('combobox', { name: 'Pressure per trial' }).inputValue()) + .toBe('0.5'); + expect( + await page.getByRole('combobox', { name: 'Probes', exact: true }).inputValue(), + ).toBe('50'); + await page.getByRole('button', { name: 'Run trials', exact: true }).click(); + await expect.poll(() => page.locator('.trial-row').count(), { timeout: 30_000 }).toBe(20); + await expect.poll(() => page.locator('.result h2').textContent()).toBe('Fresh read'); + await page.locator('#app[data-hydrated="true"]').waitFor(); + expect(await page.locator('.result').textContent()).toContain('512 KiB per trial'); + expect(await page.getByRole('link', { name: 'Read again', exact: true }).count()).toBe(0); + await page.getByRole('checkbox', { name: 'Add replication pressure' }).uncheck(); + await page.getByRole('button', { name: 'Run trials', exact: true }).click(); + await expect.poll(() => page.locator('.trial-row').count(), { timeout: 30_000 }).toBe(20); + expect(await page.locator('tbody tr.stale').count()).toBe(0); + expect( + await page.getByText('20 completed · 20 fresh · 0 stale · 0 errored').count(), + ).toBe(1); + expect(await page.locator('.result').textContent()).toContain('Off'); + + const immediate = await context.request.post( + `${server.base}/api/${variant}?experiment=immediate&pressure=1`, + { maxRedirects: 0 }, + ); + expect(immediate.status()).toBe(200); + expect(immediate.headers().location).toBeUndefined(); + expect(immediate.headers()['set-cookie']?.includes('d1-demo-bookmark=')).not.toBe(true); + const immediateObservation = await immediate.json(); + expect(immediateObservation).toMatchObject({ + experiment: 'immediate', + variant, + pressureMiB: 1, + outcome: 'fresh', + writeBookmark: expect.any(String), + outgoingBookmark: expect.any(String), + }); + expect(immediateObservation.observed).toBe(immediateObservation.expected); + expect(immediateObservation.incomingBookmark).toBe( + fixed ? immediateObservation.writeBookmark : null, + ); + expect(immediate.headers()['x-d1-bookmark']).toBe(immediateObservation.outgoingBookmark); + expect(immediateObservation.reads).toBeUndefined(); + expect(JSON.parse(immediate.headers()['x-test-session-starts'])).toEqual([ + 'first-unconstrained', + fixed ? immediateObservation.writeBookmark : 'first-unconstrained', + ]); + for (const count of [1, 16, 999]) { + const sampled = await context.request.post( + `${server.base}/api/${variant}?experiment=immediate&reads=${count}&writes=${count}&pressure=0.25`, + ); + const sampledObservation = await sampled.json(); + expect(sampled.status()).toBe(200); + expect(sampledObservation.pressureMiB).toBe(0.25); + expect(sampledObservation.reads).toBeUndefined(); + expect(sampledObservation.pressureWrites).toBeUndefined(); + expect(JSON.parse(sampled.headers()['x-test-session-starts'])).toHaveLength(2); + } + + await page.getByRole('combobox', { name: 'Read timing' }).selectOption('immediate'); + await page.getByRole('button', { name: 'Run trials', exact: true }).click(); + await expect.poll(() => page.locator('.trial-row').count(), { timeout: 30_000 }).toBe(20); + expect(await page.locator('.result h2').textContent()).toBe('Fresh read'); + expect(await page.locator('.result').textContent()).toContain('Immediate server read'); + expect(await page.locator('.result').textContent()).toContain('Read session bookmark'); + } + for (const size of [0.25, 0.5, 1, 2]) { + const response = await context.request.post( + `${server.base}/api/with-bookmark?pressure=${size}`, + ); + expect(response.status()).toBe(200); + expect(await response.json()).toMatchObject({ pressureMiB: size, outcome: 'fresh' }); + } + for (const size of [4, 16, 32, 64, 999999]) { + const invalid = await context.request.post( + `${server.base}/api/with-bookmark?pressure=${size}`, + ); + expect(await invalid.json()).toMatchObject({ pressureMiB: 0, outcome: 'fresh' }); + } + const probe = await context.request.get(`${server.base}/api/routing`); + expect(probe.status()).toBe(200); + expect(await probe.json()).toMatchObject({ + path: 'void-run', + metadata: { rows_written: 0 }, + }); + const bookmark = (await context.cookies()).find( + (cookie) => cookie.name === 'd1-demo-bookmark', + ); + expect(bookmark).toMatchObject({ httpOnly: true, sameSite: 'Lax' }); + expect((await context.request.get(`${server.base}/api/unknown`)).status()).toBe(404); + expect( + ( + await context.request.post(`${server.base}/api/with-bookmark`, { + headers: { origin: 'https://other.example' }, + }) + ).status(), + ).toBe(403); + + const readFailure = await context.request.get( + `${server.base}/api/with-bookmark?expected=acknowledged-revision`, + { headers: { 'x-demo-test-error': 'read' } }, + ); + expect(readFailure.status()).toBe(500); + expect(readFailure.headers()['cache-control']).toContain('no-store'); + expect(await readFailure.json()).toMatchObject({ + outcome: 'errored', + expected: 'acknowledged-revision', + observed: null, + error: { + phase: 'read', + status: 500, + causes: [ + { + name: 'Error', + message: 'D1_ERROR: test failure
', + stack: expect.any(String), + }, + { name: 'Error', message: 'test RPC disconnect', stack: expect.any(String) }, + ], + }, + }); + + const immediateReadFailure = await context.request.post( + `${server.base}/api/with-bookmark?experiment=immediate`, + { headers: { 'x-demo-test-error': 'immediate-read' }, maxRedirects: 0 }, + ); + expect(immediateReadFailure.status()).toBe(500); + const failedSamples = await immediateReadFailure.json(); + expect(failedSamples).toMatchObject({ + experiment: 'immediate', + outcome: 'errored', + expected: expect.any(String), + observed: null, + writeBookmark: expect.any(String), + error: { phase: 'read', status: 500 }, + }); + expect(failedSamples.reads).toBeUndefined(); + + await page.goto(`${server.base}/without-bookmark`); + await page.locator('#app[data-hydrated="true"]').waitFor(); + await page.getByRole('checkbox', { name: 'Add replication pressure' }).uncheck(); + let writes = 0; + await page.route('**/api/without-bookmark*', async (route) => { + const request = route.request(); + const headers = { ...request.headers() }; + if (request.method() === 'POST' && ++writes >= 2) headers['x-demo-test-error'] = 'save'; + await route.continue({ headers }); + }); + await page.getByRole('button', { name: 'Run trials', exact: true }).click(); + await expect + .poll(() => page.locator('.result h2').textContent()) + .toBe('Errored — trial did not complete'); + expect(await page.getByText('1 completed · 1 fresh · 0 stale · 1 errored').count()).toBe(1); + expect(await page.locator('tbody tr.fresh').count()).toBe(1); + expect(await page.locator('tbody tr.errored').count()).toBe(1); + expect(await page.locator('[role="alert"]').textContent()).toContain( + 'write may have committed', + ); + await page.locator('.error-details details summary').click(); + expect(await page.locator('.error-details details').textContent()).toContain( + 'test RPC disconnect', + ); + expect(await page.locator('.error-details details').textContent()).toContain( + 'D1_ERROR: test failure
', + ); + expect(writes).toBe(2); + await page.getByRole('button', { name: 'Run trials', exact: true }).click(); + await expect + .poll(() => page.getByText('0 completed · 0 fresh · 0 stale · 1 errored').count()) + .toBe(1); + expect(writes).toBe(3); + await context.close(); + } finally { + await browser.close(); + if (server) { + await killChild(server.child); + } + fixture.cleanup(); + } + }, 120_000); + } +}); diff --git a/packages/void/test/integration/db-session.test.ts b/packages/void/test/integration/db-session.test.ts new file mode 100644 index 000000000..fafa6520f --- /dev/null +++ b/packages/void/test/integration/db-session.test.ts @@ -0,0 +1,173 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { convertV4MiniflareOptions, Miniflare } from 'miniflare'; +import { build } from 'vite'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { analyzeApplication } from '../../src/application-routing.ts'; +import { readConfig } from '../../src/config.ts'; +import { drizzlePlugin } from '../../src/plugin-drizzle.ts'; +import { compileRouteTable, compileWorkerEntry } from '../../src/router/compile.ts'; + +const source = (file: string) => + fileURLToPath(new URL(`../../src/runtime/${file}`, import.meta.url)); +let directory: string; +let runtime: Miniflare | undefined; +afterEach(async () => { + await runtime?.dispose(); + runtime = undefined; + if (directory) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('D1 bookmarks in generated Workers', () => { + // Local D1 has no replica lag. The unit reproduction controls lag; this verifies + // real D1 bookmarks, middleware, redirects, bundling and request scope in workerd. + it.each([ + { module: 'runtime', bindingName: 'DB' }, + { module: 'generated', bindingName: 'DB' }, + { module: 'generated', bindingName: 'APP_DB' }, + ])( + '$module / $bindingName carries a bookmark through middleware', + async ({ module, bindingName }) => { + directory = mkdtempSync(join(tmpdir(), 'void-db-bookmark-worker-')); + const routes = join(directory, 'routes'); + const middleware = join(directory, 'middleware'); + const output = join(directory, 'output'); + mkdirSync(routes); + mkdirSync(middleware); + writeFileSync( + join(middleware, '01.database.ts'), + ` +import { setSession, getBookmark } from 'void/db'; +export default async (c, next) => { + setSession(c.req.header('X-DB-Bookmark') ?? 'first-unconstrained'); + await next(); + const bookmark = getBookmark(); + if (bookmark) c.header('X-DB-Bookmark', bookmark); +}; +`, + ); + writeFileSync( + join(routes, 'profile.ts'), + ` +import { db, createDb, getBookmark, sql } from 'void/db'; +export async function POST(c) { + await db.run(sql\`UPDATE profile SET name = 'Alice' WHERE id = 1\`); + return c.redirect('/profile', 303); +} +export async function GET(c) { + const name = await db.get(sql\`SELECT name FROM profile WHERE id = 1\`); + const client = createDb(c.env[${JSON.stringify(bindingName)}], { session: getBookmark() ?? 'first-unconstrained' }); + const explicit = await client.get(sql\`SELECT name FROM profile WHERE id = 1\`); + return c.json({ name, explicit, bookmark: getBookmark(client) }); +} +`, + ); + writeFileSync(join(routes, 'unused.ts'), 'export function GET() { return "unused"; }'); + const { scan } = await analyzeApplication(directory, { config: readConfig(directory) }); + const routeTable = join(directory, 'generated-routes.ts'); + const entry = join(directory, 'generated-worker.ts'); + writeFileSync( + routeTable, + compileRouteTable( + scan, + routes, + middleware, + join(directory, 'crons'), + join(directory, 'queues'), + null, + { + honoPath: fileURLToPath(import.meta.resolve('hono')), + honoRoutePath: fileURLToPath(import.meta.resolve('hono/route')), + runtimeEnvPath: source('env.ts'), + projectRoot: directory, + homeDir: tmpdir(), + auth: null, + dev: false, + reactRefresh: false, + }, + ), + ); + writeFileSync(entry, compileWorkerEntry({ runtimeEnvPath: source('env.ts') })); + await build({ + configFile: false, + logLevel: 'silent', + plugins: [ + { + name: 'bookmark-worker-fixture', + enforce: 'pre', + resolveId(id) { + if (id === 'virtual:void-routes') { + return routeTable; + } + if (id.endsWith('/dist/runtime/routing.mjs')) { + return source('routing.ts'); + } + if (id === 'void/db' && module === 'runtime') { + return source('db.ts'); + } + }, + }, + ...(module === 'generated' + ? [ + drizzlePlugin(directory, false, 'sqlite', { + d1BindingName: bindingName, + forceVirtualDb: true, + }), + ] + : []), + ], + ssr: { noExternal: true }, + build: { + outDir: output, + ssr: entry, + target: 'esnext', + rollupOptions: { + external: [/^cloudflare:/, /^node:/], + output: { entryFileNames: 'worker.mjs', codeSplitting: false }, + }, + }, + }); + runtime = new Miniflare( + convertV4MiniflareOptions({ + compatibilityDate: '2026-07-01', + compatibilityFlags: ['nodejs_compat'], + modules: true, + modulesRoot: output, + scriptPath: join(output, 'worker.mjs'), + d1Databases: [bindingName], + }), + ); + const binding = await runtime.getD1Database(bindingName); + await binding.prepare('CREATE TABLE profile (id INTEGER PRIMARY KEY, name TEXT)').run(); + await binding.prepare("INSERT INTO profile VALUES (1, 'Old name')").run(); + const saved = await runtime.dispatchFetch('https://app.test/profile', { + method: 'POST', + redirect: 'manual', + }); + expect(saved.status).toBe(303); + expect(saved.headers.get('Location')).toBe('/profile'); + const bookmark = saved.headers.get('X-DB-Bookmark'); + expect(bookmark).toBeTruthy(); + const loaded = await runtime.dispatchFetch('https://app.test/profile', { + headers: { 'X-DB-Bookmark': bookmark! }, + }); + expect(loaded.status).toBe(200); + expect(await loaded.json()).toEqual({ + name: { name: 'Alice' }, + explicit: { name: 'Alice' }, + bookmark: expect.any(String), + }); + expect(loaded.headers.get('X-DB-Bookmark')).toBeTruthy(); + const unused = await runtime.dispatchFetch('https://app.test/unused', { + headers: { 'X-DB-Bookmark': bookmark! }, + }); + expect(await unused.text()).toBe('unused'); + expect(unused.headers.has('X-DB-Bookmark')).toBe(false); + }, + 30_000, + ); +}); diff --git a/packages/void/test/unit/db-session.test.ts b/packages/void/test/unit/db-session.test.ts new file mode 100644 index 000000000..af8883b78 --- /dev/null +++ b/packages/void/test/unit/db-session.test.ts @@ -0,0 +1,286 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { Hono } from 'hono'; +import { convertV4MiniflareOptions, Miniflare } from 'miniflare'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { drizzlePlugin } from '../../src/plugin-drizzle.ts'; +import * as runtimeDb from '../../src/runtime/db.ts'; +import { withRuntimeEnv } from '../../src/runtime/env.ts'; +import { getHookHandler } from '../helpers/vite-hooks.ts'; + +const roots: Array = []; +const profile = sqliteTable('profile', { + id: integer('id').primaryKey(), + name: text('name').notNull(), +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +async function loadGeneratedDb(bindingName: string): Promise { + const root = mkdtempSync(join(tmpdir(), 'void-db-session-')); + roots.push(root); + const plugin = drizzlePlugin(root, false, 'sqlite', { + d1BindingName: bindingName, + forceVirtualDb: true, + }); + const load = getHookHandler(plugin.load) as (id: string) => string; + const path = join(root, 'db.mjs'); + writeFileSync( + path, + load('\0virtual:void-db') + .replace( + JSON.stringify(fileURLToPath(import.meta.resolve('#self/_env'))), + JSON.stringify(new URL('../../src/runtime/env.ts', import.meta.url).href), + ) + .replace( + JSON.stringify(fileURLToPath(import.meta.resolve('#self/runtime/db-session'))), + JSON.stringify(new URL('../../src/runtime/db-session.ts', import.meta.url).href), + ), + ); + return import(pathToFileURL(path).href); +} + +// Miniflare executes real D1 SQL but does not provide controllable replica lag. +// Model only the session boundary here, as in auth-d1-database.test.ts: +// PRIMARY advances after a write, REPLICA stays at the original profile. +// These opaque tokens belong to this fixture, not to Miniflare's Sessions API. +const BEFORE_SAVE = 'test-bookmark-before-save'; +const AFTER_SAVE = 'test-bookmark-after-save'; + +function laggingBinding(primary: D1Database, replica: D1Database) { + const withSession = vi.fn((start: string = 'first-unconstrained') => { + if (!['first-unconstrained', 'first-primary', BEFORE_SAVE, AFTER_SAVE].includes(start)) { + throw new Error(`Unexpected test session start: ${start}`); + } + let fresh = start === 'first-primary' || start === AFTER_SAVE; + let bookmark: string | null = start === BEFORE_SAVE || start === AFTER_SAVE ? start : null; + const session = { + getBookmark: () => bookmark, + prepare(sql: string) { + const write = /^update\b/i.test(sql); + if (!write && !/^select\b/i.test(sql)) { + throw new Error(`Unexpected test query: ${sql}`); + } + function statement(params: Array) { + // Choose at execution time, so a session's reads observe its previous writes. + async function execute(method: 'run' | 'raw') { + const database = write || fresh ? primary : replica; + const prepared = database.prepare(sql).bind(...params); + const result = await prepared[method](); + if (write) { + fresh = true; + } + bookmark = fresh ? AFTER_SAVE : BEFORE_SAVE; + return result; + } + return { + bind: (...values: Array) => statement(values), + run: () => execute('run'), + raw: () => execute('raw'), + }; + } + return statement([]); + }, + } as unknown as D1DatabaseSession; + return session; + }); + // Only the D1 operations used by this reproduction are implemented. + return { binding: { withSession } as unknown as D1Database, withSession }; +} + +const clients = [ + { module: 'runtime', bindingName: 'DB' }, + { module: 'generated', bindingName: 'DB' }, + { module: 'generated', bindingName: 'APP_DB' }, +]; + +describe('D1 cross-request bookmark reproduction (#783)', () => { + it.each(clients)( + '$module / $bindingName: preserves read-your-writes at the next request', + async (testCase) => { + const miniflare = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + script: 'export default { fetch() { return new Response("ok") } }', + d1Databases: ['PRIMARY', 'REPLICA'], + }), + ); + try { + const primary = await miniflare.getD1Database('PRIMARY'); + const replica = await miniflare.getD1Database('REPLICA'); + for (const database of [primary, replica]) { + await database + .prepare('CREATE TABLE profile (id INTEGER PRIMARY KEY, name TEXT NOT NULL)') + .run(); + await database.prepare("INSERT INTO profile VALUES (1, 'Old name')").run(); + } + const { binding, withSession } = laggingBinding( + primary as unknown as D1Database, + replica as unknown as D1Database, + ); + const { db, createDb, setSession, getBookmark } = + testCase.module === 'runtime' ? runtimeDb : await loadGeneratedDb(testCase.bindingName); + const env = { [testCase.bindingName]: binding }; + const readProfile = () => db.select().from(profile); + + const app = new Hono(); + app.use(async (c, next) => { + setSession(c.req.header('X-DB-Bookmark') ?? 'first-unconstrained'); + await next(); + const bookmark = getBookmark(); + if (bookmark) { + c.header('X-DB-Bookmark', bookmark); + } + }); + // Request A: POST /profile. The write and subsequent read share a session. + app.post('/profile', async (c) => { + expect(await readProfile()).toEqual([{ id: 1, name: 'Old name' }]); + await db.update(profile).set({ name: 'Alice' }).run(); + expect(await readProfile()).toEqual([{ id: 1, name: 'Alice' }]); + return c.redirect('/profile', 303); + }); + app.get('/profile', async (c) => c.json(await readProfile())); + const saved = await withRuntimeEnv(env, () => app.request('/profile', { method: 'POST' })); + expect(saved.status).toBe(303); + expect(saved.headers.get('Location')).toBe('/profile'); + expect(withSession).toHaveBeenCalledExactlyOnceWith('first-unconstrained'); + const bookmark = saved.headers.get('X-DB-Bookmark'); + expect(bookmark).toBe(AFTER_SAVE); + + // Request B: GET /profile after the redirect should observe the saved profile. + const loaded = await withRuntimeEnv(env, () => + app.request('/profile', { + headers: { 'X-DB-Bookmark': bookmark! }, + }), + ); + expect(await loaded.json()).toEqual([{ id: 1, name: 'Alice' }]); + expect(withSession.mock.calls.map(([start]) => start)).toEqual([ + 'first-unconstrained', + AFTER_SAVE, + ]); + + // Control: starting on primary avoids the stale read. + expect( + await createDb(binding, { session: 'first-primary' }).select().from(profile), + ).toEqual([{ id: 1, name: 'Alice' }]); + + // Explicit clients accept the same carried bookmark without casts. + const explicit = createDb(binding, { session: bookmark! }); + expect(getBookmark(explicit)).toBe(AFTER_SAVE); + expect(await explicit.select().from(profile)).toEqual([{ id: 1, name: 'Alice' }]); + expect(withSession).toHaveBeenLastCalledWith(AFTER_SAVE); + + // Neither control secretly caught up the stale replica. + expect(await withRuntimeEnv(env, readProfile)).toEqual([{ id: 1, name: 'Old name' }]); + } finally { + await miniflare.dispose(); + } + }, + 30_000, + ); +}); + +describe.each(clients)('$module / $bindingName session lifecycle', (testCase) => { + async function loadClient() { + return testCase.module === 'runtime' ? runtimeDb : loadGeneratedDb(testCase.bindingName); + } + + function mockBinding() { + const withSession = vi.fn((start: string) => ({ + getBookmark: () => (start === 'first-unconstrained' ? null : start), + })); + return { withSession, env: { [testCase.bindingName]: { withSession } } }; + } + + it('does not open a D1 session when middleware configures or inspects an unused db', async () => { + const { db, getBookmark, setSession } = await loadClient(); + const { withSession, env } = mockBinding(); + withRuntimeEnv(env, () => { + expect(getBookmark()).toBeNull(); + expect(getBookmark(db)).toBeNull(); + setSession(AFTER_SAVE); + expect(getBookmark()).toBeNull(); + }); + expect(withSession).not.toHaveBeenCalled(); + expect(withRuntimeEnv(env, getBookmark)).toBeNull(); + }); + + it('rejects reconfiguration after db is used and keeps the original session', async () => { + const { db, getBookmark, setSession } = await loadClient(); + const { withSession, env } = mockBinding(); + withRuntimeEnv(env, () => { + setSession(AFTER_SAVE); + void db.select(); + expect(() => setSession(BEFORE_SAVE)).toThrow('before the first use of db'); + expect(getBookmark()).toBe(AFTER_SAVE); + expect(getBookmark(db)).toBe(AFTER_SAVE); + }); + expect(withSession).toHaveBeenCalledExactlyOnceWith(AFTER_SAVE); + }); + + it('keeps overlapping requests and explicit clients independent', async () => { + const { db, createDb, getBookmark, setSession } = await loadClient(); + const { withSession, env } = mockBinding(); + let resume!: () => void; + const resumed = new Promise((resolve) => { + resume = resolve; + }); + const first = withRuntimeEnv(env, async () => { + setSession(AFTER_SAVE); + void db.select(); + await resumed; + expect(getBookmark()).toBe(AFTER_SAVE); + expect(() => setSession(BEFORE_SAVE)).toThrow('before the first use of db'); + }); + try { + await withRuntimeEnv(env, async () => { + setSession(BEFORE_SAVE); + const explicit = createDb(env[testCase.bindingName] as unknown as D1Database, { + session: AFTER_SAVE, + }); + await Promise.resolve(); + expect(getBookmark(explicit)).toBe(AFTER_SAVE); + expect(getBookmark()).toBeNull(); + void db.select(); + expect(getBookmark()).toBe(BEFORE_SAVE); + }); + } finally { + resume(); + await first; + } + withRuntimeEnv(env, () => { + expect(getBookmark()).toBeNull(); + void db.select(); + expect(getBookmark()).toBeNull(); + }); + expect(withSession.mock.calls.map(([start]) => start)).toEqual([ + AFTER_SAVE, + AFTER_SAVE, + BEFORE_SAVE, + 'first-unconstrained', + ]); + }); + + it('requires a Void request scope for the default-session middleware helpers', async () => { + const { getBookmark, setSession } = await loadClient(); + expect(() => setSession(AFTER_SAVE)).toThrow('outside a runtime handler'); + expect(() => getBookmark()).toThrow('outside a runtime handler'); + }); + + it('reads an explicit client bookmark without a Void request scope', async () => { + const { createDb, getBookmark } = await loadClient(); + const { withSession, env } = mockBinding(); + const client = createDb(env[testCase.bindingName] as unknown as D1Database, { + session: AFTER_SAVE, + }); + expect(getBookmark(client)).toBe(AFTER_SAVE); + expect(withSession).toHaveBeenCalledExactlyOnceWith(AFTER_SAVE); + }); +}); diff --git a/packages/void/test/unit/db-session.typecheck.ts b/packages/void/test/unit/db-session.typecheck.ts index 307d88f3d..5c4e0fffd 100644 --- a/packages/void/test/unit/db-session.typecheck.ts +++ b/packages/void/test/unit/db-session.typecheck.ts @@ -1,5 +1,6 @@ +import { integer, sqliteTable } from 'drizzle-orm/sqlite-core'; import { expectTypeOf } from 'vite-plus/test'; -import { createDb, type DrizzleD1Database } from '../../src/runtime/db.ts'; +import { createDb, getBookmark, setSession, type DrizzleD1Database } from '../../src/runtime/db.ts'; declare const binding: D1Database; @@ -7,7 +8,21 @@ expectTypeOf(createDb(binding, { session: 'first-primary' })).toEqualTypeOf(); +expectTypeOf(getBookmark(createDb(binding))).toEqualTypeOf(); + +const schema = { profile: sqliteTable('profile', { id: integer('id').primaryKey() }) }; +declare const schemaClient: DrizzleD1Database; +expectTypeOf(getBookmark(schemaClient)).toEqualTypeOf(); + +// @ts-expect-error Session starts must be strings. +createDb(binding, { session: 123 }); // @ts-expect-error Session options do not accept arbitrary settings. createDb(binding, { primary: true }); diff --git a/packages/void/test/unit/plugin-drizzle.test.ts b/packages/void/test/unit/plugin-drizzle.test.ts index 288e2c42c..bd73c35a3 100644 --- a/packages/void/test/unit/plugin-drizzle.test.ts +++ b/packages/void/test/unit/plugin-drizzle.test.ts @@ -183,6 +183,10 @@ export function drizzle(config) { /^import \{ ((?:getRuntimeBinding|getRuntimeRequestResource|requireRuntimeBinding)[^}]*) \} from .*;$/m, (_match, imports: string) => `import { ${imports} } from ${JSON.stringify(envUrl)};`, ); + code = code.replace( + /^import \{ createD1SessionAccess \} from .*;$/m, + `import { createD1SessionAccess } from ${JSON.stringify(new URL('../../src/runtime/db-session.ts', import.meta.url).href)};`, + ); writeFileSync(join(root, 'virtual-db.mjs'), code); writeFileSync( join(root, 'harness.mjs'), @@ -425,7 +429,9 @@ describe('drizzlePlugin db typing generation', () => { const dts = readFileSync(join(root, '.void', 'db.d.ts'), 'utf8'); expect(dts).toContain('import type { DrizzleD1Database } from "void/_db";'); - expect(dts).toContain('options?: { session?: D1SessionConstraint }'); + expect(dts).toContain('options?: { session?: D1SessionConstraint | D1SessionBookmark }'); + expect(dts).toContain('export declare function setSession('); + expect(dts).toContain('export declare function getBookmark('); expect(dts).toContain('): DrizzleD1Database;'); expect(dts).not.toContain('void/_db-pg'); }); @@ -901,8 +907,8 @@ export const requestPoolOptions = (env) => expect(resolved).toBe('\0virtual:void-db'); const code = load.call({}, '\0virtual:void-db'); - expect(code).toContain('requireRuntimeBinding("myapp_db")'); - expect(code).not.toContain('requireRuntimeBinding("DB")'); + expect(code).toContain('createD1SessionAccess("myapp_db")'); + expect(code).not.toContain('createD1SessionAccess("DB")'); }); it('can keep void/db off tsconfig paths for metaframeworks that materialize aliases', () => { diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index 71c0c6b37..51e3980dc 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -26,6 +26,7 @@ function injectWorkersTypesReferencePlugin() { // Patch declaration entrypoints whose source uses Cloudflare globals. const targets = new Set([ 'runtime/handler.d.mts', + 'runtime/db.d.mts', 'runtime/env-public.d.mts', 'runtime/sandbox.d.mts', 'runtime/sandbox-container.d.mts', @@ -98,6 +99,7 @@ export default defineConfig({ 'src/runtime/env-public.ts', 'src/runtime/env-public-client.ts', 'src/runtime/db.ts', + 'src/runtime/db-session.ts', 'src/runtime/db-pg.ts', 'src/runtime/db-mysql.ts', 'src/runtime/durable.ts', diff --git a/playground/d1-bookmarks/README.md b/playground/d1-bookmarks/README.md new file mode 100644 index 000000000..0703e55fa --- /dev/null +++ b/playground/d1-bookmarks/README.md @@ -0,0 +1,75 @@ +# D1 bookmarks + +React Pages demo comparing real D1 read-after-write behavior with and without bookmark propagation. Choose browser-cookie transport or an immediate server-side read through an independent session. The home page includes code snippets and VS Code source links, like `playground/http-errors`. + +## Local development + +From the repository root: + +```sh +vp install +cd playground/d1-bookmarks +vp exec void db migrate +cd ../.. +vp run --filter ./playground/d1-bookmarks dev +``` + +Open `/without-bookmark` and `/with-bookmark`. Choose the same **Read timing** and pressure settings in both variants: + +Selections persist across reloads and deployments in browser storage and the page URL, and are shared between variants. Explicit URL parameters take precedence. The selected write/read experiment is labeled at the top; trial results themselves remain in page memory. + +### Read-only routing baseline + +Start with **Probe once** or choose a **Probes** count and click **Run probes** above the write experiments. The probe count is independent of the trial count; both are saved in browser storage and the URL. Each `GET /api/routing` performs one indexed SELECT through a fresh `first-unconstrained` session using `void/db .run()`, without saves, pressure, or bookmark propagation. Probes run sequentially and stop on an error. + +Counters show primary, replica, unknown, and error totals. Each sample shows Worker colo, D1 region/colo, attempts, rows returned, wall time, and expandable complete D1 metadata, rows, bookmark, and errors. Missing `total_attempts` is shown as **not reported**, not assumed to mean one. The same complete metadata and timing are available for trial reads. Structured server logs use `d1-routing-probe` and `d1-routing-probe-error` events. + +The probe reads the first profile by indexed ID; an empty database still measures routing. It does not measure read-after-write consistency or force replica selection. Avoid running write experiments in another tab while collecting a no-write baseline. + +### Write/read experiments + +- **Browser redirect (cookie)** (default): each trial performs an API POST followed by a 303 GET and displays the result in place. Only the fixed variant transports its bookmark in an HttpOnly cookie. +- **Immediate server read**: each trial performs one POST. Both variants write through a new unconstrained session, then read once through a separate new session without a browser round trip. The control starts the reader unconstrained; the fixed variant passes the completed write's bookmark directly to the reader's `createDb()`. Neither variant reads or updates the bookmark cookie in this mode. Use `?experiment=immediate` to open this mode directly. + +Each trial shows its read target, revision, bookmarks, and D1 metadata. Counters separate primary reads, replica reads, stale replica reads, unknown targets (including local D1), and read errors. There is one read per trial, with no concurrent readers or writers. + +Choose **20** (default), **50**, **100**, **200**, or **500** trials before clicking “Run trials”. It repeats the selected experiment sequentially, displaying expected/observed revisions, stale counts, D1 primary/replica metadata, and bookmarks. Immediate mode narrows the time available for replication to catch up; D1 can still route reads to the primary, and neither mode guarantees a stale read. + +Failed requests appear as **errored**, separately from fresh/stale reads. A failed trial stops the run and stays in the history with an expandable error row. Error details include the failing phase, HTTP status, request ID when available, server error messages, stack traces, and nested causes. This diagnostic playground returns those details from its API. A failed save may have committed before its connection was interrupted; it is not automatically retried. + +Local Miniflare does not reproduce distributed replica lag: both variants normally pass. No artificial delay, fake bookmark, or simulated stale data is used. + +**Add replication pressure** is checked by default at **256 KiB per trial**. Choose **256 KiB**, **512 KiB**, **1 MiB**, or **2 MiB**. Each trial saves its revision and rewrites unrelated 256 KiB BLOB rows in one transaction, then reads the revision once through an independent session. No database operations are issued in parallel. The fixed variant's read bookmark comes from that completed write transaction. + +Payload slots are shared across visitors and reused, using at most eight slots (2 MiB). Previously written data is not deleted when choosing smaller settings. At the defaults, 20 trials write 5 MiB of pressure data. The UI shows the selected total. API `pressure=0.25`, `pressure=0.5`, `pressure=1`, and `pressure=2` select these sizes; other values disable pressure. Uncheck the option for revision-only writes. Use a disposable database; pressure does not guarantee stale reads. + +Click **Run trials** to write and read automatically using the selected timing. Every trial finishes before the next starts; there is no one-off Save button. + +## Live demo + +The Worker is named **`voidzero-void-d1-bookmarks`**, and its D1 database is **`voidzero-void-d1-bookmarks-weur`**. Their account, names, and database ID are saved in `cloudflare.config.ts` in this directory; `void.config.ts` reuses those settings for the Void build. The primary runs in Western Europe (`WEUR`), with read replication enabled (`auto`) and no jurisdiction restriction. Credentials come from the `cf` wrapper through `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`. + +Deployed URL: . Sign in through the account's Cloudflare Access login to open the playground. + +The Worker uses the Singapore placement hint `aws:ap-southeast-1`, while the D1 primary runs in WEUR. **Worker placement / colo** displays `cf-placement` when available (for example, `remote-SIN`), falling back to the request's Cloudflare colo. Placement influences where the Worker executes; it does not force D1 reads onto a replica. Check each read's primary/replica metadata separately. + +Workers Observability is enabled with persisted invocation logs and 100% log sampling. Server errors also emit a structured `d1-demo-error` log. + +To redeploy, run these commands from this directory. Build with Vite+ first, then pass the generated output to `cf`: + +```sh +vp build +cf deploy --prebuilt --mode production --dry-run +cf d1 migrations apply f4a50d02-a225-47fe-8157-0622c0553fe9 --dir db/migrations +cf deploy --prebuilt --mode production --no-provision +``` + +The migration command applies only pending files and uses the `d1_migrations` tracking table. Keep using this sequence for this database. Both checked-in migrations have been applied. Local development uses an independent SQLite database; run `vp exec void db migrate` after adopting this configuration to initialize it. + +Try the control repeatedly from a region far from the Western Europe primary, then compare the manual-bookmark variant. A stale read is timing-dependent and is not guaranteed: a replica may already be caught up, or the read may hit the primary. D1 metadata identifies where each read actually ran. [D1 automatically places replicas in every supported region](https://developers.cloudflare.com/d1/best-practices/read-replication/#read-replica-locations); individual replica locations cannot be selected. + +The database was provisioned with `cf d1 create --name voidzero-void-d1-bookmarks-weur --primary-location-hint weur --read-replication-mode auto`. Check its current region and replication settings with `cf d1 get f4a50d02-a225-47fe-8157-0622c0553fe9`. When switching databases, clear the `d1-demo-bookmark` cookie before testing the bookmark variant so it starts a session on the new database. + +The two variants and timing modes share implementation but have independent profile rows per browser. In redirect mode, only `/with-bookmark` and `/api/with-bookmark` read and update the HttpOnly bookmark cookie using `setSession()` and `getBookmark()`. The control ignores that cookie even after visiting the fixed page. `X-D1-Bookmark` exposes the outgoing read bookmark for inspection in DevTools in both experiments, but the control never sends it back. + +ISR is disabled and responses use `private, no-store`. Run in a single tab without concurrent writes: concurrent cookies can overwrite newer bookmarks, and a later save can replace the exact revision being compared. This public demo writes one profile row per visitor per variant and timing mode; use a disposable D1 database and remove it when finished. It is not an authentication or production data-management example. diff --git a/playground/d1-bookmarks/cloudflare.config.ts b/playground/d1-bookmarks/cloudflare.config.ts new file mode 100644 index 000000000..4ea10f0b2 --- /dev/null +++ b/playground/d1-bookmarks/cloudflare.config.ts @@ -0,0 +1,28 @@ +import { bindings, defineConfig } from 'cf/config'; + +export default defineConfig({ + accountId: '194274c8e6a8b3c2546413f05e622f44', + worker: { + name: 'voidzero-void-d1-bookmarks', + compatibilityDate: '2026-02-24', + compatibilityFlags: ['nodejs_als'], + entrypoint: './.void/entry.ts', + workersDev: true, + placement: { region: 'aws:ap-southeast-1' }, + observability: { + enabled: true, + headSamplingRate: 1, + logs: { + enabled: true, + invocationLogs: true, + persist: true, + }, + }, + env: { + DB: bindings.d1({ + name: 'voidzero-void-d1-bookmarks-weur', + id: 'f4a50d02-a225-47fe-8157-0622c0553fe9', + }), + }, + }, +}); diff --git a/playground/d1-bookmarks/db/migrations/20261111235451_long_silver_surfer.sql b/playground/d1-bookmarks/db/migrations/20261111235451_long_silver_surfer.sql new file mode 100644 index 000000000..16eda4527 --- /dev/null +++ b/playground/d1-bookmarks/db/migrations/20261111235451_long_silver_surfer.sql @@ -0,0 +1,5 @@ +CREATE TABLE `bookmark_profiles` ( + `id` text PRIMARY KEY NOT NULL, + `revision` text NOT NULL, + `saved_at` text NOT NULL +); diff --git a/playground/d1-bookmarks/db/migrations/20261112024822_robust_frog_thor.sql b/playground/d1-bookmarks/db/migrations/20261112024822_robust_frog_thor.sql new file mode 100644 index 000000000..9d461d785 --- /dev/null +++ b/playground/d1-bookmarks/db/migrations/20261112024822_robust_frog_thor.sql @@ -0,0 +1,4 @@ +CREATE TABLE `bookmark_pressure_chunks` ( + `id` integer PRIMARY KEY NOT NULL, + `payload` blob NOT NULL +); diff --git a/playground/d1-bookmarks/db/migrations/meta/20261111235451_snapshot.json b/playground/d1-bookmarks/db/migrations/meta/20261111235451_snapshot.json new file mode 100644 index 000000000..1554530c8 --- /dev/null +++ b/playground/d1-bookmarks/db/migrations/meta/20261111235451_snapshot.json @@ -0,0 +1,49 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d3ab64ca-4c40-441b-9fab-61fbdc8cb89c", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "bookmark_profiles": { + "name": "bookmark_profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "saved_at": { + "name": "saved_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/playground/d1-bookmarks/db/migrations/meta/20261112024822_snapshot.json b/playground/d1-bookmarks/db/migrations/meta/20261112024822_snapshot.json new file mode 100644 index 000000000..0c00bacf4 --- /dev/null +++ b/playground/d1-bookmarks/db/migrations/meta/20261112024822_snapshot.json @@ -0,0 +1,73 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "57fdfcdf-e304-4236-aab1-27b848708481", + "prevId": "d3ab64ca-4c40-441b-9fab-61fbdc8cb89c", + "tables": { + "bookmark_pressure_chunks": { + "name": "bookmark_pressure_chunks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmark_profiles": { + "name": "bookmark_profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "saved_at": { + "name": "saved_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/playground/d1-bookmarks/db/migrations/meta/_journal.json b/playground/d1-bookmarks/db/migrations/meta/_journal.json new file mode 100644 index 000000000..58cba499a --- /dev/null +++ b/playground/d1-bookmarks/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1794441291547, + "tag": "20261111235451_long_silver_surfer", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1794451702064, + "tag": "20261112024822_robust_frog_thor", + "breakpoints": true + } + ] +} diff --git a/playground/d1-bookmarks/db/schema.ts b/playground/d1-bookmarks/db/schema.ts new file mode 100644 index 000000000..7325f7c84 --- /dev/null +++ b/playground/d1-bookmarks/db/schema.ts @@ -0,0 +1,12 @@ +import { blob, integer, sqliteTable, text } from 'void/schema-d1'; + +export const profiles = sqliteTable('bookmark_profiles', { + id: text('id').primaryKey(), + revision: text('revision').notNull(), + savedAt: text('saved_at').notNull(), +}); + +export const pressureChunks = sqliteTable('bookmark_pressure_chunks', { + id: integer('id').primaryKey(), + payload: blob('payload', { mode: 'buffer' }).notNull(), +}); diff --git a/playground/d1-bookmarks/middleware/database.ts b/playground/d1-bookmarks/middleware/database.ts new file mode 100644 index 000000000..d7e03ae2b --- /dev/null +++ b/playground/d1-bookmarks/middleware/database.ts @@ -0,0 +1,94 @@ +import { getCookie, setCookie } from 'hono/cookie'; +import { defineMiddleware } from 'void'; +import { getBookmark, setSession } from 'void/db'; +import { + BOOKMARK_COOKIE, + errorObservation, + experimentForQuery, + pressureMiBForQuery, + serializeError, + variantForPath, + VISITOR_COOKIE, +} from '../src/demo.ts'; + +export default defineMiddleware(async (c, next) => { + const variant = variantForPath(c.req.path); + if (!variant) { + await next(); + return; + } + + c.header('Cache-Control', 'private, no-store'); + const cookieOptions = { + httpOnly: true, + secure: new URL(c.req.url).protocol === 'https:', + sameSite: 'Lax' as const, + path: '/', + }; + let visitor = getCookie(c, VISITOR_COOKIE); + if (!visitor || !/^[0-9a-f-]{36}$/.test(visitor)) { + visitor = crypto.randomUUID(); + setCookie(c, VISITOR_COOKIE, visitor, cookieOptions); + } + c.set('demoVisitor', visitor); + const experiment = experimentForQuery(c.req.query('experiment')); + + // The control ignores the bookmark cookie, even after visiting the fixed variant. + if (experiment === 'redirect' && variant === 'with-bookmark') { + setSession(getCookie(c, BOOKMARK_COOKIE) ?? 'first-unconstrained'); + } + + await next(); + + if (c.error) { + // Log messages explicitly: workerd's Error rendering may contain only the stack. + const errors = serializeError(c.error); + console.error( + JSON.stringify({ + event: 'd1-demo-error', + method: c.req.method, + path: c.req.path, + pressure: c.req.query('pressure') ?? '0', + experiment, + errors, + }), + ); + if (c.req.path.startsWith('/api/')) { + // This diagnostic playground deliberately returns server errors for inspection. + c.header('Cache-Control', 'private, no-store'); + const observation = errorObservation( + variant, + pressureMiBForQuery(c.req.query('pressure')), + { + phase: c.req.method === 'POST' && !c.get('demoWriteRevision') ? 'save' : 'read', + status: c.res.status, + requestId: c.req.header('cf-ray') ?? null, + causes: errors, + }, + (c.get('demoWriteRevision') as string | undefined) ?? + (c.req.method === 'GET' ? (c.req.query('expected') ?? null) : null), + experiment, + ); + observation.incomingBookmark = + experiment === 'immediate' + ? ((c.get('demoReadBookmark') as string | undefined) ?? null) + : variant === 'with-bookmark' + ? (getCookie(c, BOOKMARK_COOKIE) ?? null) + : null; + observation.writeBookmark = (c.get('demoWriteBookmark') as string | undefined) ?? null; + const response = c.json(observation); + c.res = new Response(response.body, { status: c.res.status, headers: response.headers }); + } + return; + } + + if (experiment === 'immediate') return; + + const bookmark = getBookmark(); + if (bookmark) { + c.header('X-D1-Bookmark', bookmark); + if (variant === 'with-bookmark') { + setCookie(c, BOOKMARK_COOKIE, bookmark, cookieOptions); + } + } +}); diff --git a/playground/d1-bookmarks/package.json b/playground/d1-bookmarks/package.json new file mode 100644 index 000000000..7ac2077a0 --- /dev/null +++ b/playground/d1-bookmarks/package.json @@ -0,0 +1,26 @@ +{ + "name": "playground-d1-bookmarks", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "dev": "vp dev", + "preview": "vp preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@void/react": "workspace:*", + "cf": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "void": "workspace:*" + } +} diff --git a/playground/d1-bookmarks/pages/index.tsx b/playground/d1-bookmarks/pages/index.tsx new file mode 100644 index 000000000..f6117c13a --- /dev/null +++ b/playground/d1-bookmarks/pages/index.tsx @@ -0,0 +1,85 @@ +import { Link } from '@void/react'; +import { SourceLink } from '../src/source-link.tsx'; + +export default function Home() { + return ( + <> +

D1 Bookmark Example

+

+ A write succeeds on the primary. A read in the next request can reach a replica that has not + caught up yet. Compare the same write/read flow with and without a bookmark, using a browser + redirect or an immediate server-side read through a separate session. +

+

+ Related:{' '} + Issue #783 | PR #XXX: TBD +

+
+ How to see the difference +
    +
  1. Deploy to Cloudflare with D1 read replication enabled.
  2. +
  3. + Open the control and choose “Browser redirect (cookie)” or “Immediate server read”. + Choose a count and “Run trials”. +
  4. +
  5. Look for a stale read: the observed revision differs from the acknowledged write.
  6. +
  7. + Repeat with the same timing and pressure in the manual-bookmark variant, which passes + the write’s bookmark to the read session. +
  8. +
+

+ Immediate mode removes the browser round trip between the write and read. Both variants + create one independent read session per trial; only the fixed variant initializes it with + the write bookmark. Each trial finishes before the next starts. Browser redirect mode + demonstrates transport through an HttpOnly cookie instead. +

+

+ This demo does not fake replica lag. Local D1 usually reads fresh in both variants, and + even a live replica may already be caught up. A fresh control read is not proof of + consistency. Try from a region far from the primary. The demo disables ISR and response + caching. +

+

+ Each browser has its own profile per variant. Run trials sequentially in one tab: + concurrent saves and cookie responses can race. Bookmarks do not promise to include + unrelated later writes from another client. +

+

+ + Cloudflare: resume a D1 session from a bookmark + +

+
+
+
+
+

Without bookmark

+

+ +

+
{'// No bookmark transport.'}
+

+ Every request starts unconstrained. A successful save can be followed by a stale read. +

+ Playground +
+
+

Manual bookmark

+

+ +

+
{`setSession(getCookie(c, 'd1-demo-bookmark') ?? 'first-unconstrained');
+await next();
+const bookmark = getBookmark();
+if (bookmark) setCookie(c, 'd1-demo-bookmark', bookmark, options);`}
+

+ Middleware resumes the previous session using an HttpOnly cookie. The redirected read + sees data at least as up-to-date as the acknowledged save. +

+ Playground +
+
+ + ); +} diff --git a/playground/d1-bookmarks/pages/layout.tsx b/playground/d1-bookmarks/pages/layout.tsx new file mode 100644 index 000000000..79142026b --- /dev/null +++ b/playground/d1-bookmarks/pages/layout.tsx @@ -0,0 +1,21 @@ +import { Link } from '@void/react'; +import type { ReactNode } from 'react'; +import './styles.css'; + +export default function Layout({ children }: { children: ReactNode }) { + return ( +
+
+ +
+
+
{children}
+
+ ); +} diff --git a/playground/d1-bookmarks/pages/styles.css b/playground/d1-bookmarks/pages/styles.css new file mode 100644 index 000000000..422d0999d --- /dev/null +++ b/playground/d1-bookmarks/pages/styles.css @@ -0,0 +1,50 @@ +.container { + box-sizing: border-box; + max-width: 100rem; + margin-inline: auto; + padding: 1rem; +} +.container p { + max-width: 75ch; +} +.controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; +} +.variants { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 1rem 2rem; +} +.variants > section { + flex: 1 1 22rem; + min-width: 0; +} +pre { + white-space: pre-wrap; +} +pre, +code { + overflow-wrap: anywhere; +} +.table-scroll { + overflow-x: auto; +} + +.result.fresh h2, +tr.fresh td:nth-child(2), +.fresh-count { + color: green; +} + +.result.stale h2, +.result.errored h2, +tr.errored td:nth-child(2), +.error-count, +tr.stale td:nth-child(2), +.stale-count { + color: #b00020; +} diff --git a/playground/d1-bookmarks/pages/with-bookmark.server.ts b/playground/d1-bookmarks/pages/with-bookmark.server.ts new file mode 100644 index 000000000..da8fa1066 --- /dev/null +++ b/playground/d1-bookmarks/pages/with-bookmark.server.ts @@ -0,0 +1,4 @@ +export { loader, action } from '../src/database.ts'; + +export const revalidate = 0; +export const prerender = false; diff --git a/playground/d1-bookmarks/pages/with-bookmark.tsx b/playground/d1-bookmarks/pages/with-bookmark.tsx new file mode 100644 index 000000000..9f446e134 --- /dev/null +++ b/playground/d1-bookmarks/pages/with-bookmark.tsx @@ -0,0 +1 @@ +export { default } from '../src/demo-page.tsx'; diff --git a/playground/d1-bookmarks/pages/without-bookmark.server.ts b/playground/d1-bookmarks/pages/without-bookmark.server.ts new file mode 100644 index 000000000..da8fa1066 --- /dev/null +++ b/playground/d1-bookmarks/pages/without-bookmark.server.ts @@ -0,0 +1,4 @@ +export { loader, action } from '../src/database.ts'; + +export const revalidate = 0; +export const prerender = false; diff --git a/playground/d1-bookmarks/pages/without-bookmark.tsx b/playground/d1-bookmarks/pages/without-bookmark.tsx new file mode 100644 index 000000000..9f446e134 --- /dev/null +++ b/playground/d1-bookmarks/pages/without-bookmark.tsx @@ -0,0 +1 @@ +export { default } from '../src/demo-page.tsx'; diff --git a/playground/d1-bookmarks/routes/api/[variant].ts b/playground/d1-bookmarks/routes/api/[variant].ts new file mode 100644 index 000000000..09bc6b973 --- /dev/null +++ b/playground/d1-bookmarks/routes/api/[variant].ts @@ -0,0 +1 @@ +export { loader as GET, action as POST } from '../../src/database.ts'; diff --git a/playground/d1-bookmarks/routes/api/routing.ts b/playground/d1-bookmarks/routes/api/routing.ts new file mode 100644 index 000000000..1b71bf17e --- /dev/null +++ b/playground/d1-bookmarks/routes/api/routing.ts @@ -0,0 +1,46 @@ +import { defineHandler } from 'void'; +import { createDb, getBookmark, sql } from 'void/db'; +import { serializeError, type RoutingProbe } from '../../src/demo.ts'; + +export const GET = defineHandler(async (c) => { + c.header('Cache-Control', 'private, no-store'); + // No writes, pressure, request-scoped db, or incoming bookmark cookies here. + // Each probe reads one indexed row through one fresh session. + const query = 'SELECT revision, saved_at FROM bookmark_profiles ORDER BY id LIMIT 1'; + const workerColo = + c.req.header('cf-placement') ?? + (typeof c.req.raw.cf?.colo === 'string' ? c.req.raw.cf.colo : null); + const path = 'void-run'; + const started = performance.now(); + try { + const client = createDb(c.env.DB, { session: 'first-unconstrained' }); + const result = await client.run(sql.raw(query)); + const probe: RoutingProbe = { + path, + workerColo, + elapsedMs: performance.now() - started, + metadata: { ...result.meta }, + rows: result.results, + bookmark: getBookmark(client), + }; + console.log(JSON.stringify({ event: 'd1-routing-probe', ...probe })); + return c.json(probe); + } catch (cause) { + const probe: RoutingProbe = { + path, + workerColo, + elapsedMs: performance.now() - started, + metadata: null, + rows: [], + bookmark: null, + error: { + phase: 'probe', + status: 500, + requestId: c.req.header('cf-ray') ?? null, + causes: serializeError(cause), + }, + }; + console.error(JSON.stringify({ event: 'd1-routing-probe-error', ...probe })); + return c.json(probe, 500); + } +}); diff --git a/playground/d1-bookmarks/src/database.ts b/playground/d1-bookmarks/src/database.ts new file mode 100644 index 000000000..c8013d0c9 --- /dev/null +++ b/playground/d1-bookmarks/src/database.ts @@ -0,0 +1,131 @@ +import { pressureChunks, profiles } from '@schema'; +import { getCookie } from 'hono/cookie'; +import { HTTPException } from 'hono/http-exception'; +import { defineHandler, type CloudContext } from 'void'; +import { createDb, db, getBookmark, sql } from 'void/db'; +import { + BOOKMARK_COOKIE, + experimentForQuery, + pressureMiBForQuery, + variantForPath, + type Observation, + type Variant, +} from './demo.ts'; + +function variant(c: CloudContext): Variant { + const value = variantForPath(c.req.path); + if (!value) { + throw new HTTPException(404); + } + return value; +} + +function profileId(c: CloudContext): string { + const suffix = experimentForQuery(c.req.query('experiment')) === 'immediate' ? ':immediate' : ''; + return `${String(c.get('demoVisitor'))}:${variant(c)}${suffix}`; +} + +async function readObservation( + c: CloudContext, + client: typeof db, + expected: string | null, + incomingBookmark: string | null, + writeBookmark: string | null = null, +): Promise { + const mode = variant(c); + const started = performance.now(); + // Keep the raw D1 metadata from the very read being compared with the write receipt. + const result = await client.run(sql` + SELECT revision, saved_at FROM ${profiles} WHERE ${profiles.id} = ${profileId(c)} + `); + const row = result.results[0] as { revision: string; saved_at: string } | undefined; + const observed = row?.revision ?? null; + return { + experiment: experimentForQuery(c.req.query('experiment')), + pressureMiB: pressureMiBForQuery(c.req.query('pressure')), + variant: mode, + expected, + observed, + savedAt: row?.saved_at ?? null, + outcome: expected === null ? 'idle' : observed === expected ? 'fresh' : 'stale', + incomingBookmark, + writeBookmark, + outgoingBookmark: getBookmark(client), + servedByPrimary: result.meta.served_by_primary ?? null, + servedByRegion: result.meta.served_by_region ?? null, + metadata: { ...result.meta }, + elapsedMs: performance.now() - started, + workerColo: + c.req.header('cf-placement') ?? + (typeof c.req.raw.cf?.colo === 'string' ? c.req.raw.cf.colo : null), + }; +} + +export const loader = defineHandler(async (c): Promise => { + const immediate = experimentForQuery(c.req.query('experiment')) === 'immediate'; + return readObservation( + c, + immediate ? createDb(c.env.DB) : db, + c.req.query('expected') ?? null, + !immediate && variant(c) === 'with-bookmark' ? (getCookie(c, BOOKMARK_COOKIE) ?? null) : null, + ); +}); + +export const action = defineHandler(async (c) => { + const origin = c.req.header('origin'); + if (origin && origin !== new URL(c.req.url).origin) { + throw new HTTPException(403, { message: 'Use the demo from the same origin.' }); + } + const mode = variant(c); + const revision = crypto.randomUUID(); + const savedAt = new Date().toISOString(); + const pressureMiB = pressureMiBForQuery(c.req.query('pressure')); + const immediate = experimentForQuery(c.req.query('experiment')) === 'immediate'; + // Both immediate variants start an independent, unconstrained write session. + const writer = immediate ? createDb(c.env.DB) : db; + const save = writer + .insert(profiles) + .values({ id: profileId(c), revision, savedAt }) + .onConflictDoUpdate({ target: profiles.id, set: { revision, savedAt } }); + // One transaction per trial, including optional BLOB pressure. No parallel writers. + // Reuse up to eight 256 KiB slots; existing larger experiments are left untouched. + if (pressureMiB) { + await writer.batch([ + save, + ...Array.from({ length: pressureMiB * 4 }, (_, id) => + writer + .insert(pressureChunks) + .values({ id, payload: sql`randomblob(262144)` }) + .onConflictDoUpdate({ + target: pressureChunks.id, + set: { payload: sql`randomblob(262144)` }, + }), + ), + ]); + } else { + await save; + } + // Mark the acknowledged write before reading so failures keep the write receipt. + c.set('demoWriteRevision', revision); + if (immediate) { + const writeBookmark = getBookmark(writer); + c.set('demoWriteBookmark', writeBookmark); + if (mode === 'with-bookmark' && writeBookmark === null) { + throw new Error('D1 did not return a bookmark for the acknowledged write.'); + } + const incomingBookmark = mode === 'with-bookmark' ? writeBookmark : null; + c.set('demoReadBookmark', incomingBookmark); + const reader = createDb(c.env.DB, { session: incomingBookmark ?? 'first-unconstrained' }); + const observation = await readObservation(c, reader, revision, incomingBookmark, writeBookmark); + if (observation.outgoingBookmark) c.header('X-D1-Bookmark', observation.outgoingBookmark); + return c.json(observation); + } + + // Carry only the expected revision in the URL, not the observed value or a D1 bookmark. + // Following this redirect issues a genuinely separate read request. + const path = c.req.path.startsWith('/api/') ? `/api/${mode}` : `/${mode}`; + return c.redirect( + `${path}?expected=${revision}${pressureMiB ? `&pressure=${pressureMiB}` : ''}`, + 303, + ); +}); diff --git a/playground/d1-bookmarks/src/demo-page.tsx b/playground/d1-bookmarks/src/demo-page.tsx new file mode 100644 index 000000000..58bdbeb9e --- /dev/null +++ b/playground/d1-bookmarks/src/demo-page.tsx @@ -0,0 +1,475 @@ +import { Fragment, useEffect, useState } from 'react'; +import { + DEFAULT_PRESSURE_MIB, + errorObservation, + experimentForQuery, + experimentLabel, + PRESSURE_OPTIONS, + pressureLabel, + RUN_COUNTS, + type Observation, + type TrialError, +} from './demo.ts'; +import { MetadataDetails } from './metadata.tsx'; +import { RoutingProbePanel } from './routing-probe.tsx'; +import { SourceLink } from './source-link.tsx'; + +export default function DemoPage(props: Observation) { + // Keyed by the variant so client navigation never mixes the two trial histories. + return ; +} + +function Trials({ initial }: { initial: Observation }) { + const [results, setResults] = useState>([]); + const [running, setRunning] = useState(false); + const [trialsRunning, setTrialsRunning] = useState(false); + const [trialCount, setTrialCount] = useState(20); + const [runSize, setRunSize] = useState(20); + const [pressure, setPressure] = useState(true); + const [pressureMiB, setPressureMiB] = useState(initial.pressureMiB || DEFAULT_PRESSURE_MIB); + const [experiment, setExperiment] = useState(initial.experiment); + const [probeCount, setProbeCount] = useState(20); + const [settingsReady, setSettingsReady] = useState(false); + useEffect(() => { + let saved: Record = {}; + try { + saved = JSON.parse(localStorage.getItem('d1-demo-settings-v1') ?? '{}') ?? {}; + } catch { + // Browser storage may be unavailable. The URL still persists selections. + } + const params = new URLSearchParams(location.search); + const value = (key: string) => params.get(key) ?? saved[key]; + const timing = value('experiment'); + if (timing === 'redirect' || timing === 'immediate') setExperiment(timing); + const trials = Number(value('trials')); + if (RUN_COUNTS.some((count) => count === trials)) setTrialCount(trials); + const probes = Number(value('probes')); + if (RUN_COUNTS.some((count) => count === probes)) setProbeCount(probes); + const size = Number( + params.get('size') ?? params.get('pressure') ?? saved.size ?? saved.pressure, + ); + if (PRESSURE_OPTIONS.some((option) => option === size)) setPressureMiB(size); + if (value('pressure') !== undefined) setPressure(Number(value('pressure')) > 0); + setSettingsReady(true); + }, []); + useEffect(() => { + if (!settingsReady) return; + const settings = { + experiment, + trials: String(trialCount), + probes: String(probeCount), + pressure: String(pressure ? pressureMiB : 0), + size: String(pressureMiB), + }; + try { + localStorage.setItem('d1-demo-settings-v1', JSON.stringify(settings)); + } catch { + /* URL fallback. */ + } + const url = new URL(location.href); + url.searchParams.delete('reads'); + url.searchParams.delete('writes'); + for (const [key, value] of Object.entries(settings)) url.searchParams.set(key, value); + history.replaceState(history.state, '', url); + }, [settingsReady, experiment, trialCount, probeCount, pressure, pressureMiB]); + const controlsDisabled = running || !settingsReady; + const immediate = experiment === 'immediate'; + const fixed = initial.variant === 'with-bookmark'; + const latest = results.at(-1) ?? initial; + const fresh = results.filter((result) => result.outcome === 'fresh').length; + const stale = results.filter((result) => result.outcome === 'stale').length; + const errored = results.filter((result) => result.outcome === 'errored').length; + + async function runTrials(count: number) { + setRunSize(count); + setRunning(true); + setTrialsRunning(true); + setResults([]); + try { + for (let index = 0; index < count; index++) { + // Redirect mode follows the 303; immediate mode writes and reads in this POST. + const response = await fetch( + `/api/${initial.variant}?experiment=${experiment}${pressure ? `&pressure=${pressureMiB}` : ''}`, + { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + }, + ); + if (!response.ok) { + const text = await response.text(); + let failure = errorObservation( + initial.variant, + pressure ? pressureMiB : 0, + { + phase: immediate ? 'request' : response.redirected ? 'read' : 'save', + status: response.status, + requestId: response.headers.get('cf-ray'), + causes: [{ name: 'HTTP error', message: text || response.statusText, stack: null }], + }, + null, + experiment, + ); + try { + const body: Observation = JSON.parse(text); + if (body.outcome === 'errored' && body.error) failure = body; + } catch { + // Infrastructure failures can return plain text or HTML instead of app JSON. + } + setResults((previous) => [...previous, failure]); + break; + } + const observation: Observation = await response.json(); + setResults((previous) => [...previous, observation]); + } + } catch (cause) { + const failure = errorObservation( + initial.variant, + pressure ? pressureMiB : 0, + { + phase: 'request', + status: null, + requestId: null, + causes: [ + { + name: cause instanceof Error ? cause.name : 'Error', + message: cause instanceof Error ? cause.message : String(cause), + stack: cause instanceof Error ? (cause.stack ?? null) : null, + }, + ], + }, + null, + experiment, + ); + setResults((previous) => [...previous, failure]); + } finally { + setRunning(false); + setTrialsRunning(false); + } + } + + return ( + <> +

{fixed ? 'Manual bookmark propagation' : 'Without bookmark propagation'}

+

+ Selected write/read experiment: {experimentLabel(experiment)}. Settings are + shared between variants and saved in this browser and URL. Explicit URL settings take + precedence. +

+ +
+

Write/read experiment

+

+ +

+

+ {immediate + ? fixed + ? 'Each trial writes, then reads once through a new session initialized with that write’s bookmark, passed directly in server code.' + : 'Each trial writes, then reads once through a new unconstrained session. The write’s bookmark is not passed to the read.' + : fixed + ? 'The POST returns its bookmark in a cookie. The redirected GET resumes from that bookmark.' + : 'The POST and redirected GET have independent, unconstrained sessions. No bookmark is sent back.'} +

+
+        {immediate
+          ? fixed
+            ? `// After the write completes on writer:
+const bookmark = getBookmark(writer);
+if (!bookmark) throw new Error('Missing write bookmark');
+const reader = createDb(c.env.DB, { session: bookmark });
+// Read once through reader, not writer.`
+            : `// After the write completes on writer:
+const reader = createDb(c.env.DB, { session: 'first-unconstrained' });
+// Read once through reader, not writer.`
+          : fixed
+            ? `setSession(getCookie(c, 'd1-demo-bookmark') ?? 'first-unconstrained');
+await next();
+const bookmark = getBookmark();
+if (bookmark) setCookie(c, 'd1-demo-bookmark', bookmark, options);`
+            : '// No setSession() call; no bookmark cookie is read or written.'}
+      
+ {immediate && ( +

+ Both variants use the same write and read query with separate sessions. There is no + browser round trip between them. Each trial finishes before the next starts. D1 still + chooses each read's destination. +

+ )} +

+ · +

+
+

+ {' '} + {' '} + — {pressure ? pressureMiB * trialCount : 0} MiB per {trialCount} trials. This does not + guarantee lag. Use a disposable database. +

+ {pressure && ( +

+ The revision and unrelated BLOB rows are saved in one transaction, followed by one read. + No database operations run in parallel. +

+ )} +
+ + +
+
+

+ {latest.outcome === 'errored' + ? 'Errored — trial did not complete' + : latest.outcome === 'idle' + ? 'Ready to run trials' + : latest.outcome === 'fresh' + ? 'Fresh read' + : 'Stale read — save acknowledged, but not visible'} +

+ {latest.error &&

{failureMessage(latest.error)}

} +
+
Read timing
+
{experimentLabel(latest.experiment)}
+
Expected revision (write receipt)
+
+ + {latest.expected ?? + (latest.error ? 'Save not acknowledged' : 'No save in this URL yet')} + +
+
Observed revision (database read)
+
+ + {latest.observed ?? (latest.error ? 'Read did not complete' : 'No row visible')} + +
+
Observed saved time
+
{latest.savedAt ?? '—'}
+
Replication pressure
+
{latest.pressureMiB ? `${pressureLabel(latest.pressureMiB)} per trial` : 'Off'}
+
Read served by
+
+ {latest.error + ? 'No completed read' + : latest.servedByPrimary === null + ? 'Unknown (local D1 does not report this)' + : latest.servedByPrimary + ? 'Primary' + : 'Replica'}{' '} + · {latest.servedByRegion ?? 'unknown region'} +
+
Worker placement / colo
+
{latest.workerColo ?? 'Unknown / local'}
+ {latest.experiment === 'immediate' && ( + <> +
Write bookmark
+
+ {latest.writeBookmark ?? 'None'} +
+ + )} +
+ {latest.experiment === 'immediate' ? 'Read session bookmark' : 'Incoming bookmark'} +
+
+ {latest.incomingBookmark ?? (fixed ? 'None yet' : 'Not carried (control)')} +
+
Outgoing bookmark
+
+ {latest.outgoingBookmark ?? 'None'} +
+ {latest.error && ( + <> +
Error
+
+ +
+ + )} +
+ +
+
+

Trial history

+

+ {fresh + stale} completed · {fresh} fresh ·{' '} + {stale} stale ·{' '} + {errored} errored +

+ +
+ + + + + + + + + + + + {results.map((result, index) => ( + + + + + + + + + {result.metadata && ( + + + + )} + {result.error && ( + + + + )} + + ))} + +
TrialResultRead targetExpectedObserved
{index + 1}{result.outcome} + {result.error + ? '—' + : result.servedByPrimary === null + ? 'unknown' + : result.servedByPrimary + ? 'primary' + : 'replica'}{' '} + / {result.servedByRegion ?? 'local'} + + {result.expected} + + {result.observed ?? (result.error ? '—' : 'missing')} +
+ +
+ +
+
+

+ Replica lag is not guaranteed. Both variants will usually look fresh locally. Run in one tab + without concurrent writes; the expected revision is a comparison value, not a bookmark. + Trials stay in this page’s memory and reset when you leave it. +

+ + ); +} + +function ReadCounts({ reads }: { reads: Observation[] }) { + const completed = reads.filter((read) => read.outcome === 'fresh' || read.outcome === 'stale'); + return ( +

+ Reads: {completed.filter((read) => read.servedByPrimary === true).length} primary ·{' '} + {completed.filter((read) => read.servedByPrimary === false).length} replica ·{' '} + + { + completed.filter((read) => read.servedByPrimary === false && read.outcome === 'stale') + .length + }{' '} + stale replica + {' '} + · {completed.filter((read) => read.servedByPrimary === null).length} unknown target ·{' '} + + {reads.filter((read) => read.outcome === 'errored').length} read errors + +

+ ); +} + +function failureMessage(error: TrialError): string { + const status = error.status === null ? 'Request failed' : `HTTP ${error.status}`; + const outcome = + error.phase === 'probe' + ? 'The read-only probe failed; no write was attempted.' + : error.phase === 'save' + ? 'Save was not acknowledged; the write may have committed.' + : error.phase === 'read' + ? 'Save was acknowledged, but the follow-up read failed.' + : 'The request did not complete; the write outcome may be unknown.'; + return `${status}. ${outcome}`; +} + +function ErrorDetails({ error }: { error: TrialError }) { + return ( +
+ {error.causes[0]?.message || 'Error details'} +

{failureMessage(error)}

+

+ Phase: {error.phase} · Request ID: {error.requestId ?? 'Unavailable'} +

+ {error.causes.map((cause, index) => ( +
+

+ + {index === 0 ? 'Error' : `Cause ${index}`}: {cause.name} + +

+
{cause.message}
+
{cause.stack ?? 'No stack trace available.'}
+
+ ))} +
+ ); +} diff --git a/playground/d1-bookmarks/src/demo.ts b/playground/d1-bookmarks/src/demo.ts new file mode 100644 index 000000000..a5f47031c --- /dev/null +++ b/playground/d1-bookmarks/src/demo.ts @@ -0,0 +1,110 @@ +export type Variant = 'without-bookmark' | 'with-bookmark'; +export type Experiment = 'redirect' | 'immediate'; + +export function experimentForQuery(value: string | undefined): Experiment { + return value === 'immediate' ? 'immediate' : 'redirect'; +} + +export function experimentLabel(experiment: Experiment): string { + return experiment === 'immediate' ? 'Immediate server read' : 'Browser redirect (cookie)'; +} + +export const PRESSURE_OPTIONS = [0.25, 0.5, 1, 2] as const; +export const DEFAULT_PRESSURE_MIB = 0.25; +export const RUN_COUNTS = [20, 50, 100, 200, 500] as const; + +export function pressureLabel(size: number): string { + return size < 1 ? `${size * 1024} KiB` : `${size} MiB`; +} + +export function pressureMiBForQuery(value: string | undefined): number { + const size = Number(value); + return PRESSURE_OPTIONS.some((option) => option === size) ? size : 0; +} + +export type Observation = { + experiment: Experiment; + pressureMiB: number; + variant: Variant; + expected: string | null; + observed: string | null; + savedAt: string | null; + outcome: 'idle' | 'fresh' | 'stale' | 'errored'; + incomingBookmark: string | null; + writeBookmark: string | null; + outgoingBookmark: string | null; + servedByPrimary: boolean | null; + servedByRegion: string | null; + workerColo: string | null; + metadata?: Record; + elapsedMs?: number; + error?: TrialError; +}; + +export type TrialError = { + phase: 'save' | 'read' | 'request' | 'probe'; + status: number | null; + requestId: string | null; + causes: Array<{ name: string; message: string; stack: string | null }>; +}; + +export type RoutingProbe = { + path: 'void-run'; + workerColo: string | null; + elapsedMs: number; + metadata: Record | null; + rows: unknown[]; + bookmark: string | null; + error?: TrialError; +}; + +export function serializeError(cause: unknown): TrialError['causes'] { + const errors: TrialError['causes'] = []; + for (let depth = 0; cause !== undefined && depth < 5; depth++) { + errors.push( + cause instanceof Error + ? { name: cause.name, message: cause.message, stack: cause.stack ?? null } + : { name: 'Cause', message: String(cause), stack: null }, + ); + cause = cause instanceof Error ? cause.cause : undefined; + } + return errors; +} + +export function errorObservation( + variant: Variant, + pressureMiB: number, + error: TrialError, + expected: string | null = null, + experiment: Experiment = 'redirect', +): Observation { + return { + experiment, + variant, + pressureMiB, + outcome: 'errored', + expected, + observed: null, + savedAt: null, + incomingBookmark: null, + writeBookmark: null, + outgoingBookmark: null, + servedByPrimary: null, + servedByRegion: null, + workerColo: null, + error, + }; +} + +export const BOOKMARK_COOKIE = 'd1-demo-bookmark'; +export const VISITOR_COOKIE = 'd1-demo-visitor'; + +export function variantForPath(path: string): Variant | null { + if (path === '/with-bookmark' || path === '/api/with-bookmark') { + return 'with-bookmark'; + } + if (path === '/without-bookmark' || path === '/api/without-bookmark') { + return 'without-bookmark'; + } + return null; +} diff --git a/playground/d1-bookmarks/src/metadata.tsx b/playground/d1-bookmarks/src/metadata.tsx new file mode 100644 index 000000000..ad61f0eb2 --- /dev/null +++ b/playground/d1-bookmarks/src/metadata.tsx @@ -0,0 +1,18 @@ +export function MetadataDetails({ + metadata, + elapsedMs, +}: { + metadata?: Record; + elapsedMs?: number; +}) { + if (!metadata) return null; + return ( +
+ + D1 metadata · attempts: {String(metadata.total_attempts ?? 'not reported')} ·{' '} + {elapsedMs?.toFixed(1) ?? '—'} ms wall time + +
{JSON.stringify(metadata, null, 2)}
+
+ ); +} diff --git a/playground/d1-bookmarks/src/routing-probe.tsx b/playground/d1-bookmarks/src/routing-probe.tsx new file mode 100644 index 000000000..0c1b3a954 --- /dev/null +++ b/playground/d1-bookmarks/src/routing-probe.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react'; +import { RUN_COUNTS, serializeError, type RoutingProbe } from './demo.ts'; +import { MetadataDetails } from './metadata.tsx'; +import { SourceLink } from './source-link.tsx'; + +export function RoutingProbePanel({ + running, + setRunning, + count, + setCount, +}: { + running: boolean; + setRunning: (value: boolean) => void; + count: number; + setCount: (value: number) => void; +}) { + const [results, setResults] = useState([]); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + + async function run(rounds: number) { + setRunning(true); + setResults([]); + setProgress(0); + setError(null); + try { + for (let round = 0; round < rounds; round++) { + const response = await fetch('/api/routing', { + cache: 'no-store', + credentials: 'same-origin', + }); + const text = await response.text(); + let body: RoutingProbe; + try { + body = JSON.parse(text); + } catch { + throw new Error( + `HTTP ${response.status}; request ID: ${response.headers.get('cf-ray') ?? 'unavailable'}\n${text}`, + ); + } + if (body.path !== 'void-run') throw new Error(`HTTP ${response.status}\n${text}`); + setResults((previous) => [...previous, body]); + setProgress(round + 1); + if (!response.ok || body.error) break; + } + } catch (cause) { + setError(JSON.stringify(serializeError(cause), null, 2)); + } finally { + setRunning(false); + } + } + + return ( +
+

Read-only routing baseline

+

+ No saves, no pressure, no bookmark propagation. Each probe issues one SELECT through a fresh{' '} + first-unconstrained session using void/db .run(). Probes are + sequential. An empty result still measures routing, not staleness. +

+
{'SELECT revision, saved_at FROM bookmark_profiles ORDER BY id LIMIT 1'}
+

+ +

+

+ {' '} + {' '} + {' '} + {progress} probes completed +

+ {error && ( +
+ Probe request failed +
{error}
+
+ )} +

+ void-run:{' '} + { + results.filter((result) => !result.error && result.metadata?.served_by_primary === true) + .length + }{' '} + primary ·{' '} + { + results.filter((result) => !result.error && result.metadata?.served_by_primary === false) + .length + }{' '} + replica ·{' '} + { + results.filter( + (result) => !result.error && typeof result.metadata?.served_by_primary !== 'boolean', + ).length + }{' '} + unknown · {results.filter((result) => result.error).length} errors +

+
+ + + + + + + + + + + + + + {results.map((result, index) => ( + + + + + + + + + + ))} + +
ProbeWorkerD1 target / region / coloAttemptsRows returnedWall timeDetails
{index + 1}{result.workerColo ?? 'unknown'} + {result.error + ? 'failed' + : result.metadata?.served_by_primary === true + ? 'primary' + : result.metadata?.served_by_primary === false + ? 'replica' + : 'unknown'}{' '} + / {String(result.metadata?.served_by_region ?? 'unknown')} /{' '} + {String(result.metadata?.served_by_colo ?? 'unknown')} + {String(result.metadata?.total_attempts ?? 'not reported')}{result.error ? '—' : result.rows.length}{result.elapsedMs.toFixed(1)} ms + +
+ Rows / bookmark / error +
+                      {JSON.stringify(
+                        { rows: result.rows, bookmark: result.bookmark, error: result.error },
+                        null,
+                        2,
+                      )}
+                    
+
+
+
+
+ ); +} diff --git a/playground/d1-bookmarks/src/source-link.tsx b/playground/d1-bookmarks/src/source-link.tsx new file mode 100644 index 000000000..660728517 --- /dev/null +++ b/playground/d1-bookmarks/src/source-link.tsx @@ -0,0 +1,14 @@ +declare const __D1_BOOKMARKS_SOURCE_ROOT__: string; + +export function SourceLink({ file }: { file: string }) { + const source = new URL( + file.split('/').map(encodeURIComponent).join('/'), + __D1_BOOKMARKS_SOURCE_ROOT__, + ); + const host = source.host ? `//${source.host}` : ''; + return ( + + {file} + + ); +} diff --git a/playground/d1-bookmarks/src/vite-env.d.ts b/playground/d1-bookmarks/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/playground/d1-bookmarks/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/playground/d1-bookmarks/tsconfig.json b/playground/d1-bookmarks/tsconfig.json new file mode 100644 index 000000000..1f640d2a3 --- /dev/null +++ b/playground/d1-bookmarks/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "./.void/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "types": ["void/env"], + "allowImportingTsExtensions": true + }, + "include": ["pages", "routes", "middleware", "src", "db", "*.config.ts"] +} diff --git a/playground/d1-bookmarks/vite.config.ts b/playground/d1-bookmarks/vite.config.ts new file mode 100644 index 000000000..00fad9b0c --- /dev/null +++ b/playground/d1-bookmarks/vite.config.ts @@ -0,0 +1,10 @@ +import { voidReact } from '@void/react/plugin'; +import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + define: { + __D1_BOOKMARKS_SOURCE_ROOT__: JSON.stringify(new URL('./', import.meta.url).href), + }, + plugins: [voidPlugin(), voidReact()], +}); diff --git a/playground/d1-bookmarks/void.config.ts b/playground/d1-bookmarks/void.config.ts new file mode 100644 index 000000000..f63534417 --- /dev/null +++ b/playground/d1-bookmarks/void.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'void/config'; +import cloudflare from './cloudflare.config.ts'; + +export default defineConfig({ + routing: { isr: false }, + cloudflare: { + account_id: cloudflare.accountId, + name: cloudflare.worker.name, + compatibility_date: cloudflare.worker.compatibilityDate, + compatibility_flags: cloudflare.worker.compatibilityFlags, + workers_dev: cloudflare.worker.workersDev, + placement: cloudflare.worker.placement, + observability: { + enabled: cloudflare.worker.observability.enabled, + head_sampling_rate: cloudflare.worker.observability.headSamplingRate, + logs: { + enabled: cloudflare.worker.observability.logs.enabled, + invocation_logs: cloudflare.worker.observability.logs.invocationLogs, + persist: cloudflare.worker.observability.logs.persist, + }, + }, + d1_databases: [ + { + binding: 'DB', + database_name: cloudflare.worker.env.DB.name, + database_id: cloudflare.worker.env.DB.id, + migrations_dir: 'db/migrations', + }, + ], + }, +}); diff --git a/playground/d1-bookmarks/void.lock.json b/playground/d1-bookmarks/void.lock.json new file mode 100644 index 000000000..c99bcab01 --- /dev/null +++ b/playground/d1-bookmarks/void.lock.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "source": { + "account_id": "194274c8e6a8b3c2546413f05e622f44", + "name": "voidzero-void-d1-bookmarks", + "compatibility_date": "2026-02-24", + "compatibility_flags": ["nodejs_als"], + "workers_dev": true, + "placement": { + "region": "aws:ap-southeast-1" + }, + "observability": { + "enabled": true, + "head_sampling_rate": 1, + "logs": { + "enabled": true, + "invocation_logs": true, + "persist": true + } + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "voidzero-void-d1-bookmarks-weur", + "database_id": "f4a50d02-a225-47fe-8157-0622c0553fe9", + "migrations_dir": "db/migrations" + } + ] + }, + "resolved": { + "account_id": "194274c8e6a8b3c2546413f05e622f44", + "name": "voidzero-void-d1-bookmarks", + "compatibility_date": "2026-02-24", + "compatibility_flags": ["nodejs_als"], + "workers_dev": true, + "observability": { + "enabled": true, + "head_sampling_rate": 1, + "logs": { + "enabled": true, + "invocation_logs": true, + "persist": true + } + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "voidzero-void-d1-bookmarks-weur", + "database_id": "f4a50d02-a225-47fe-8157-0622c0553fe9", + "migrations_dir": "db/migrations" + } + ], + "placement": { + "region": "aws:ap-southeast-1" + } + }, + "text": "{\n \"account_id\": \"194274c8e6a8b3c2546413f05e622f44\",\n \"name\": \"voidzero-void-d1-bookmarks\",\n \"compatibility_date\": \"2026-02-24\",\n \"compatibility_flags\": [\n \"nodejs_als\"\n ],\n \"workers_dev\": true,\n \"observability\": {\n \"enabled\": true,\n \"head_sampling_rate\": 1,\n \"logs\": {\n \"enabled\": true,\n \"invocation_logs\": true,\n \"persist\": true\n }\n },\n \"d1_databases\": [\n {\n \"binding\": \"DB\",\n \"database_name\": \"voidzero-void-d1-bookmarks-weur\",\n \"database_id\": \"f4a50d02-a225-47fe-8157-0622c0553fe9\",\n \"migrations_dir\": \"db/migrations\"\n }\n ],\n \"placement\": {\n \"region\": \"aws:ap-southeast-1\"\n }\n}\n" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f44dec15..ddb137c78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1351,6 +1351,43 @@ importers: specifier: workspace:* version: link:../../packages/void + playground/d1-bookmarks: + dependencies: + hono: + specifier: 'catalog:' + version: 4.13.17 + react: + specifier: 'catalog:' + version: 19.3.1 + react-dom: + specifier: 'catalog:' + version: 19.3.1(react@19.3.1) + devDependencies: + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@void/react': + specifier: workspace:* + version: link:../../packages/react + cf: + specifier: 'catalog:' + version: 1.0.0-beta.28(@types/node@26.6.6) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@1.6.0(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)' + vite-plus: + specifier: 'catalog:' + version: 1.6.0(@astrojs/compiler-rs@0.5.2)(@types/node@26.6.6)(@vitest/ui@5.1.0)(esbuild@0.28.2)(happy-dom@21.0.4)(jiti@2.7.0)(playwright@1.65.0)(sass-embedded@1.104.1)(sass@1.104.1)(svelte@5.57.2)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1) + void: + specifier: workspace:* + version: link:../../packages/void + playground/kitchen-sink: dependencies: hono: