@lunora/d1 is the D1-backed storage adapter for tables marked
.global(). It wraps the Workers D1Database binding with the Sessions
API (so read-your-writes works across replicas) and ships a migration
runner the CLI's migrate command relies on.
import { D1Client } from "@lunora/d1";
const client = new D1Client(env.DB);
const session = client.withSession(request.headers.get("x-d1-bookmark") ?? undefined);
const { results } = await session.all("SELECT * FROM users WHERE id = ?", userId);D1Client
The entry point. Construct it with the bare D1 binding: new D1Client(env.DB).
The constructor takes the binding only; bookmarks are passed per session, not
at construction.
client.withSession(bookmark?): open aD1Sessionpinned to a bookmark for read-your-writes. With no bookmark it opens an explicit"first-unconstrained"session (lowest latency, served from any replica).client.prepare(sql): prepared statement on the bare binding (no bookmark pinning); statements are LRU-cached per client.client.drizzle/client.drizzleSession(bookmark?): drizzle-orm/d1 handles for typed queries against generatedsqliteTableschemas.drizzleruns on the bare binding (no Sessions API);drizzleSessionopts into a bookmark.client.batch([...]): atomic batch over the drizzle d1 driver, mirroringdb.batch([...]).client.raw: the underlying binding (advanced use).
D1Session
Sessions-API handle. Reads and writes on the same session thread the bookmark
through, so a read after a write sees that write. run / all / first take
the SQL string followed by positional bind values (variadic, not an array):
const session = client.withSession(bookmark);
await session.run("UPDATE users SET name = ? WHERE id = ?", "Ada", userId);
const row = await session.first("SELECT * FROM users WHERE id = ?", userId);
// Forward this back to the client (e.g. as `x-d1-bookmark`) so the next request
// reads its own writes. `undefined` until D1 has issued a bookmark.
const bookmark = session.getBookmark();session.prepare(sql) returns the raw prepared statement when you need the
.bind(...).all() / .run() / .raw() chain directly — unretried, like
everything on D1Client. See Transient failures and
retries.
D1DatabaseLike / D1PreparedStatementLike / D1SessionLike
Structural type aliases. Useful in tests when you want to swap in a mock without depending on the full Workers types package.
MigrationRunner
Applies a sequence of Migration records against a D1 database in version
order. Each migration's SQL is hashed (SHA-256) and the hash is recorded in the
__drizzle_migrations table, so applied migrations are skipped idempotently and
re-running is a no-op.
import { MigrationRunner } from "@lunora/d1";
const runner = new MigrationRunner(env.DB, [{ version: 1, name: "init", sql: readFileSync("migrations/001_init.sql", "utf8") }]);
const { applied, skipped } = await runner.run();MigrationRunnerResult.applied is the migrations applied this run (each { name, version }); skipped is the ones whose SQL hash was already recorded. The
constructor accepts a D1Client or a bare D1 binding (it wraps the binding for
you). It rejects duplicate versions and two migrations with identical SQL at
construction time. Use lunora migrate generate to produce the SQL;
MigrationRunner is what applies it at deploy time.
Migration
interface Migration {
version: number; // monotonically increasing integer; orders application
name: string; // human label, e.g. "001_init" (used in logs)
sql: string; // raw SQL — exactly one statement per migration
}A migration must be a single SQL statement (a trailing ; is allowed). The
runner parses with a quote- and comment-aware lexer and throws if it finds a
second statement; split multi-statement DDL across separate Migration entries.
Transient failures and retries
D1 has a documented, expected, non-zero baseline error rate. Cloudflare's own team describes a handful of errors every few hours as "not unexpected" even on a healthy database, and their guidance is to "retry their query and unless there is an underlying issue with the database, it should eventually work". The failures are infrastructural, not query-shaped:
| Error | Cause |
|---|---|
D1 DB storage operation exceeded timeout which caused object to be reset | Storage object recycled mid-operation |
D1_ERROR: Network connection lost | Dropped connection — the most common |
Internal error while starting up D1 DB storage caused object to be reset | Storage object failed to come up |
D1 DB's isolate exceeded its memory limit and was reset | Isolate evicted; no query rewrite avoids it |
The last two are judgement calls rather than certainties: an isolate over its memory limit, or a storage operation past its timeout, is transient when the runtime recycled it under unrelated load and deterministic when the query is simply too big for D1 to serve. In the second case the retries are wasted work before the same failure surfaces — if you see them repeat on one query, the query is the problem.
An application that does not retry has adopted that error rate as its own, so Lunora retries for you — with exponential backoff and full jitter, so a fleet of Workers that hit the same blip does not re-converge on the recovering database as one synchronised wave.
Where the retry actually is
.global() tables read through an executor Lunora builds over your D1 binding,
and that executor retries. Nothing else does automatically:
| Path | Retries |
|---|---|
ctx.db on a .global() table | yes, read-only statements |
session.all(...) / session.first(...) | yes, read-only statements |
session.run(...) | no |
client.prepare / client.drizzle / client.batch | no |
D1Client's own methods hand back the underlying D1 surface untouched — the
drizzle handles included. Wrap those in withD1Retry yourself when you want it,
and only for an operation that is safe to run twice.
Retrying without a timeout makes a stall worse
D1's reported failure mode is not only a fast error — it is a stall. Operators report the storage object hanging for 30+ seconds before resetting, several times a day, on databases holding tens of megabytes of data with simple queries.
A retry loop wrapped around a stall amplifies it: three attempts at 30 seconds
each is a 90-second request instead of a 30-second one. So when you call
withD1Retry yourself, bound it:
await withD1Retry(() => session.all("SELECT * FROM reports"), {
timeoutMs: 2000, // abandon an attempt that has clearly hung
deadlineMs: 5000, // and bound the whole operation
});timeoutMs bounds one attempt; deadlineMs bounds the operation — including an
attempt still in flight when the budget runs out, and the backoff before the next
one — so attempts × timeoutMs plus backoff cannot compound past what a request
handler can afford. An abandoned attempt throws D1TimeoutError — its own class,
so "D1 hung" is distinguishable from "D1 returned an error"; those have different
remedies and collapsing them hides which one you have.
Neither has a default. A legitimate analytical query and a stalled one look identical from outside, so the value has to come from what your workload needs. With neither set — which is how the automatic paths run — a failure that took longer than two seconds is treated as a stall and not retried, so the unconfigured case cannot compound one hang into three.
A timeout abandons the wait, not the work. There is no way to cancel an in-flight D1 operation from a Worker, so the subrequest continues and still counts against the request's subrequest budget. What you buy is that your request stops waiting — the difference between a fast error and a user watching a spinner for half a minute.
Retries are not enough on a critical write path
If a write must not surface a 500 to a user, retrying harder is the wrong lever. Cloudflare's own guidance is to decouple the write from the HTTP response: accept the request, enqueue the write, and let the queue consumer absorb the storage event.
// The user-facing handler never touches D1.
export const captureLead = action.input({ email: v.string() }).action(async ({ ctx, args }) => {
await ctx.queues.leads.send(args);
return { accepted: true };
});@lunora/queue retries the consumer independently of the request, so a D1 stall
costs a delayed write rather than a failed signup. Use this for anything where
losing the request is worse than applying it late — lead capture, audit trails,
webhook side effects.
Read-only statements retry; everything else does not
Every error above is ambiguous about whether the statement applied. "The
connection dropped" does not say whether it dropped before or after the write
committed, and D1 has no interactive transactions to resolve it. Silently
re-running UPDATE accounts SET balance = balance - 10 because the response was
lost is how a retry layer turns a transient blip into a corrupted balance.
The method is not the signal — the statement is. D1 runs
UPDATE … RETURNING through .all() exactly like .run(), and Lunora's own
optimistic-concurrency check does precisely that. So the automatic paths retry
only when the statement's leading keyword is SELECT, PRAGMA or EXPLAIN.
Anything else — including a WITH … whose tail cannot be proven read-only —
runs once and surfaces its error.
Writes that are genuinely idempotent — an upsert keyed on a primary key, a
delete by id, an INSERT OR IGNORE — can opt in per call:
import { withD1Retry } from "@lunora/d1";
const session = client.withSession(bookmark);
await withD1Retry(() => session.run("INSERT OR IGNORE INTO seen (id) VALUES (?)", id));withD1Retry takes attempts, timeoutMs and deadlineMs. There is no
per-client retry configuration: the automatic paths run the defaults, and a
caller who needs different behaviour is already calling withD1Retry directly.
isTransientD1Error is exported if you are classifying errors yourself. It is
deliberately conservative: only the specific messages in the table above are
transient, and anything else — a constraint violation, a syntax error, a missing
table, internal error: too many SQL variables — is treated as permanent and
surfaces immediately, because re-running one turns a single fast failure into
three slow ones and hides the cause.
Atomicity
Each migration's body and its tracking-table INSERT run together in one
client.batch([...]), which D1 executes as an implicit transaction, so they
commit or roll back as a unit. A body that committed before a failed tracking
write would otherwise re-apply on the next run, which matters for
non-idempotent migrations.
@lunora/d1/dialect
A dependency-free subpath describing how .global() tables are physically
shaped in D1. The runtime (which auto-provisions tables) and the CLI's
migrate generate SQL emitter both derive their DDL from it, so a generated
migration matches the table the runtime creates byte for byte. Exports
quoteIdentifier, sqlAffinityForKind, frameworkColumnDdl, columnRef,
physicalIndexName, and the SqlAffinity type. You rarely import this directly;
it exists so those two code paths stay in lockstep.