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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/guide/database/d1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
22 changes: 21 additions & 1 deletion docs/reference/api/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Schema>;

// PostgreSQL
Expand All @@ -63,6 +65,24 @@ function createDb(connectionString: string): NodePgDatabase<Schema>;
function createDb(connectionString: string): MySql2Database<Schema>;
```

## `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<Schema>): 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:
Expand Down
1 change: 1 addition & 0 deletions packages/void/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/void/skills/void/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
8 changes: 7 additions & 1 deletion packages/void/src/codegen/db-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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;`,
]
: []),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is clearly wrong, not all sqlite will have this API, but I didn't have time to figure out what is the best approach here.

Ideally we should know if we're running D1 or not and only then generate types, but maybe there's a cleaner solution like void/d1 or something like that.

`export { ${DRIZZLE_OPERATORS} } from "void/_db";`,
`export type { ${type} } from "${from}";`,
'',
Expand Down
20 changes: 14 additions & 6 deletions packages/void/src/plugin-drizzle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down Expand Up @@ -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);
},
Expand All @@ -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});
}
`;
},
},
Expand Down
49 changes: 49 additions & 0 deletions packages/void/src/runtime/db-session.ts
Original file line number Diff line number Diff line change
@@ -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<RequestSession>(
key,
() => ({ start: 'first-unconstrained' }),
// Preserve primary-first reads for framework handlers without Void's scope.
() => ({ start: 'first-primary' }),
);
return (current.session ??= requireRuntimeBinding<D1Database>(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;
},
};
}
27 changes: 22 additions & 5 deletions packages/void/src/runtime/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TSchema extends Record<string, unknown>>(
client?: DrizzleD1Database<TSchema>,
): D1SessionBookmark | null {
return sessions.getBookmark((client as object | undefined) === db ? undefined : client);
}

const requestDbKey = {};
Expand Down Expand Up @@ -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);
},
Expand Down
Loading
Loading