Skip to content
DocspackagesDocumentation

@lunora/d1

D1 Sessions API client + migration runner for global tables.

PackagesD1

@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 a D1Session pinned 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 generated sqliteTable schemas. drizzle runs on the bare binding (no Sessions API); drizzleSession opts into a bookmark.
  • client.batch([...]): atomic batch over the drizzle d1 driver, mirroring db.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:

ErrorCause
D1 DB storage operation exceeded timeout which caused object to be resetStorage object recycled mid-operation
D1_ERROR: Network connection lostDropped connection — the most common
Internal error while starting up D1 DB storage caused object to be resetStorage object failed to come up
D1 DB's isolate exceeded its memory limit and was resetIsolate 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:

PathRetries
ctx.db on a .global() tableyes, read-only statements
session.all(...) / session.first(...)yes, read-only statements
session.run(...)no
client.prepare / client.drizzle / client.batchno

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.