Zero-configuration database abstraction for Cloudflare Durable Objects with SQLite.
Define your schema with Drizzle, query with Kysely, validate with ArkType -- and let the Vite plugin handle the rest: Durable Object class generation, RPC stubs, migration embedding, and wrangler config verification (with opt-in auto-patching).
- Quick Start
- Defining a Database
- Writing Actions
- Calling Actions from Your Worker
- CLI
- Migration System
- Vite Plugin
- Outerbase Studio Integration
- Instance Strategies
- Action-to-Action Calls
- PITR Safety
- Recovery Workflows
- Architecture
- Development
pnpm add durable-db// src/databases/schema.ts
import { table, text, integer } from 'durable-db/schema';
export const users = table('users', {
id: text().primaryKey(),
name: text().notNull(),
email: text().notNull(),
createdAt: integer({ mode: 'timestamp' }).notNull(),
});Column names are derived from JS property keys and auto-converted to snake_case (e.g. createdAt → created_at). Table names passed to table() are also auto-snake_cased.
Date columns: use integer({ mode: 'timestamp' }) and pass Date values — they round-trip as Date objects automatically. Plain text() columns always round-trip verbatim: if you store an ISO string (new Date().toISOString()), you get the exact same string back, never a Date.
⚠️ All tables referenced indefineDatabase({ schema })must be exported from the schema module and imported into the database file. Tables defined inline in the database file (or not exported) cannot be loaded by the migration CLI —db push/db generatewill fail with an explicit error rather than silently generating empty migrations.
// src/databases/main.ts
import { defineDatabase } from 'durable-db';
import { users } from './schema';
export const { action } = defineDatabase({
schema: { users },
});// src/databases/actions/createUser.ts
import { action } from '../main';
export const createUser = action({
args: {
name: 'string',
email: 'string.email',
},
handler: async (db, args) => {
return db
.insertInto('users')
.values({
id: crypto.randomUUID(),
name: args.name,
email: args.email,
createdAt: new Date(),
})
.returningAll()
.executeTakeFirstOrThrow();
},
});// vite.config.ts
import { defineConfig } from 'vite';
import { cloudflare } from '@cloudflare/vite-plugin';
import { durableDb } from 'durable-db/vite';
export default defineConfig({
plugins: [
durableDb(),
cloudflare(),
],
});// src/worker.ts
import { setTenantIdResolver } from 'durable-db';
import { createUser } from './databases/actions/createUser';
// In a real app, resolve from authentication/session
setTenantIdResolver(() => 'my-tenant');
// Export the generated Durable Object classes
export * from 'virtual:durable-db/__durableObjects';
export default {
async fetch(request: Request, env: any) {
const user = await createUser({ name: 'Alice', email: 'alice@example.com' });
return Response.json(user);
},
};For TypeScript support, add durable-db/virtual to your tsconfig.json:
{
"compilerOptions": {
"types": ["durable-db/virtual"]
}
}db push # Create dev migrations from your schema
pnpm dev # Start the dev serverCall defineDatabase() with a config object. The Vite plugin parses this at build time to generate a Durable Object class.
import { defineDatabase } from 'durable-db';
import { users, posts } from './schema';
export const { action } = defineDatabase({
schema: { users, posts },
instance: 'per-tenant', // or 'global' (default: 'per-tenant')
transport: 'rpc', // or 'websocket' (default: 'rpc')
browsable: 'development', // Outerbase Studio integration (default: false)
});The destructured action function is your factory for creating database actions.
| Option | Type | Default | Description |
|---|---|---|---|
schema |
object |
{} |
Drizzle schema tables |
instance |
'per-tenant' | 'global' |
'per-tenant' |
Instance strategy (see Instance Strategies) |
transport |
'rpc' | 'websocket' |
'rpc' |
Transport for action stubs. WebSocket uses Cloudflare's 20:1 message billing ratio for cheaper high-volume calls |
browsable |
boolean | 'development' |
false |
Enable Outerbase SQL browsing (see Outerbase Studio) |
Actions are type-safe database operations with runtime argument validation.
import { action } from '../main';
export const getUser = action({
args: { userId: 'string' },
handler: async (db, args, ctx) => {
return db
.selectFrom('users')
.selectAll()
.where('id', '=', args.userId)
.executeTakeFirst();
},
});Uses ArkType syntax for runtime validation:
args: { name: 'string' } // Required string
args: { email: 'string.email' } // Email validation
args: { limit: 'number > 0' } // Positive number
args: { offset: 'number >= 0' } // Non-negative number
args: { tags: 'string[]' } // Array of strings
args: { name: 'string', age: 'number?' } // Optional field
args: { role: "'admin' | 'user'" } // Literal unionReceives three arguments:
| Argument | Type | Description |
|---|---|---|
db |
Kysely<Schema> |
Type-safe Kysely query builder bound to your Drizzle schema |
args |
inferred from args |
Validated arguments (ArkType ensures correctness at runtime) |
ctx |
ActionContext |
Context with env (Cloudflare bindings) and instanceKey |
Actions can live inline in the database file or in separate files:
src/databases/
main.ts # defineDatabase() call (may also contain action() definitions)
schema.ts # Drizzle schema (excluded from action discovery)
actions/
createUser.ts # import { action } from '../main'
getUser.ts
listUsers.ts
Both styles are fully supported: the Vite plugin transforms action() definitions in the database file itself exactly like those in separate files — they're registered with the DO and rewritten into RPC stubs.
Files named schema.ts, _*.ts, and .d.ts are excluded from action discovery.
For per-tenant databases, actions need a tenant ID to know which Durable Object instance to use. Call setTenantIdResolver() once at startup to provide it:
import { setTenantIdResolver } from 'durable-db';
import { createUser } from './databases/actions/createUser';
import { listUsers } from './databases/actions/listUsers';
// In a real app, resolve from authentication/session
setTenantIdResolver(() => 'example-tenant');
export default {
async fetch(request: Request, env: any) {
if (request.method === 'POST') {
const body = await request.json();
const user = await createUser({ name: body.name, email: body.email });
return Response.json(user);
}
const users = await listUsers({ limit: 10, offset: 0 });
return Response.json(users);
},
};If your framework has request-scoped context (e.g. RWSDK's getRequestInfo()), use the resolver to bridge the two:
import { setTenantIdResolver } from 'durable-db';
import { getRequestInfo } from 'rwsdk/worker';
setTenantIdResolver(() => getRequestInfo().ctx.session!.shop);The resolver is called at the moment a database operation needs the tenant ID — by which point request middleware (auth, session, etc.) has already completed.
Behind the scenes, each action call is an RPC call to the correct Durable Object instance. The Vite plugin generates stubs that handle instance routing, argument validation, and DO communication transparently.
The db CLI manages your migration lifecycle. All commands share these options:
| Flag | Default | Description |
|---|---|---|
-d, --databases-dir <dir> |
src/databases |
Directory containing database definitions |
-v, --verbose |
false |
Show detailed output |
Schema loading is strict. When a database declares tables in defineDatabase({ schema }), every command that loads the schema (push, generate, status, validate) fails with exit code 1 — instead of reporting "no changes" — if:
- the tables are defined inline in the database file instead of a schema module
- the schema import can't be resolved
- the schema module fails to build
- any declared table is missing from the schema module's exports
A database with no schema declared at all is still skipped silently — that's a valid (if unusual) configuration.
Push schema changes to dev migrations. This is the command you run most often during development.
db push
db push --verbose
db push --databases-dir ./src/dbWhat it does:
- Discovers all database files in your databases directory
- Parses each file for
defineDatabase()calls - For each database:
- Loads the current production snapshot (
_snapshot.jsonin the migrations directory) - Generates a fresh snapshot from your current Drizzle schema
- Diffs the two snapshots to produce SQL migration statements
- If there are changes: replaces any previous dev migration with a single squashed migration named after a hash of its content (e.g.
dev_a1b2c3d4.sql).CREATE TABLE/CREATE INDEXstatements getIF NOT EXISTSadded so the squashed migration can overlay tables a previous dev migration already created - If the same content hash already exists, nothing is rewritten — the DO recognizes the migration name and skips it
- Loads the current production snapshot (
Output example:
✓ main: dev_a1b2c3d4 (3 statements)
· analytics: no changes
When nothing changed anywhere:
· main: no changes
· analytics: no changes
All databases are up to date.
Dev migrations are stored in node_modules/.cache/durable-db/ and are never committed to git. They are loaded automatically by the Vite plugin in dev mode.
Generate a production migration from your current schema changes. Run this when you're ready to commit.
db generate
db generate add_user_bio
db generate --database main
db generate --database main add_posts_tableArguments:
| Argument | Description |
|---|---|
[name] |
Optional suffix appended to the timestamp-based migration name |
Extra flags:
| Flag | Description |
|---|---|
--database <db> |
Only generate for this specific database |
What it does:
- Compares your current Drizzle schema against the production snapshot (
_snapshot.json) - Generates SQL statements for the diff
- Writes a timestamped
.sqlfile to the configuredmigrationsDir - Updates the production snapshot with a new ID and
prevIdchain - Clears dev state for this database (the dev migrations are now superseded by the production migration)
Output example:
✓ main: 20240315123045_add_user_bio
→ migrations/main/20240315123045_add_user_bio.sql
2 statement(s)
Migration file naming:
- Without name argument:
20240315123045.sql - With name argument:
20240315123045_add_user_bio.sql
Show the current migration status for all databases without making any changes.
db status
db status --verboseOutput example:
Dev Epoch: m1a2b3c
📦 main
Production migrations: 3
Dev migrations: 1
📝 Uncommitted changes: 1 statement(s)
Pending SQL:
- ALTER TABLE users ADD COLUMN bio TEXT
Last push: 2024-03-15T10:30:00.000Z
📦 analytics
Production migrations: 1
Dev migrations: 0
✓ Schema is up to date
Shows per-database: production migration count, dev migration count, whether there are uncommitted schema changes (pending SQL statements that haven't been pushed yet), and the last push timestamp. If a teammate committed new production migrations since your last push, a ⚠️ Production snapshot changed - run 'db:reset' to sync warning appears.
Reset dev state and create fresh database instances via an epoch bump.
db reset
db reset --keep-epoch
db reset --database main
db reset --database main --keep-epoch
db reset --purge-local-storageExtra flags:
| Flag | Description |
|---|---|
--keep-epoch |
Only clear dev migrations; keep the same DO instances |
--database <db> |
Only reset this specific database |
--purge-local-storage |
Also delete workerd's persisted DO storage under .wrangler/ (requires the dev server to be stopped) |
Two modes:
| Mode | What happens |
|---|---|
| Full reset (default) | Bumps the epoch. All databases rotate to brand-new DO instances on the next request — fresh, empty SQLite with migrations re-applied. Clears all dev migrations and snapshots. Works while the dev server is running. |
Keep epoch (--keep-epoch) |
Only clears dev migrations and snapshots. Existing DO instances keep running with their current data. |
How fresh instances work: in dev, every DO instance key is suffixed with the current epoch (<key>__dev_<epoch>) by the generated stubs. Bumping the epoch makes idFromName() resolve to entirely new DO instances, so the old tables can never collide with the new schema. The Vite dev server watches the dev-state file and reloads automatically when the epoch changes — no restart needed.
Disk space: the previous instances' SQLite files stay orphaned under .wrangler/state/v3/do until you purge them. Run db reset --purge-local-storage (with the dev server stopped — workerd keeps deleted storage open and breaks with internal error; reference = … until restart) or simply rm -rf .wrangler whenever you want the disk back. With --database <db>, only the storage directories matching that database's DO class are purged.
Output example:
✓ New epoch: n4d5e6f — databases start fresh on the next request
✓ Reset databases: main, analytics
Dry-run all migrations against a local in-memory SQLite database to catch errors before deployment.
db validate
db validate --database main
db validate --no-dev
db validate --verboseExtra flags:
| Flag | Description |
|---|---|
--database <db> |
Only validate this specific database |
--no-dev |
Skip dev migrations, only validate production migrations |
What it does:
- Creates an in-memory SQLite database using
libsql - Applies all migrations sequentially (production + dev, unless
--no-dev) - Builds the expected schema separately from the Drizzle definition
- Compares the migrated schema against the expected schema to detect drift
- Reports SQL errors, foreign key violations, or schema mismatches
Output example (success):
✓ main: 5 migration(s) (includes dev migrations)
Schema matches ✓
Output example (failure):
✗ main: 5 migration(s)
✗ 20240315123045_bad[0]: near "INVALID": syntax error
Schema drift (migrations ran cleanly but produce a different schema than your Drizzle definition) is reported as:
⚠ Schema drift detected:
[missing] table users: column bio
Exit codes:
| Code | Meaning |
|---|---|
0 |
All validations passed |
1 |
Validation errors found |
The commands are composable Commander commands, so a host CLI can re-use them:
import { Command } from 'commander';
import { createDbCommand, registerDbCommands } from 'durable-db/cli';
// As a nested `db` group: `mycli db push`
program.addCommand(createDbCommand());
// Or flat on your own command: `mycli push`
// (this is exactly what the standalone `db` binary does)
registerDbCommands(program);All CLI functions are also available for integration into other tools:
import * as db from 'durable-db/cli';
const pushResults = await db.push({ verbose: true });
const statusResults = await db.status();
const generateResults = await db.generate({}, { name: 'add_bio' });
const resetResult = await db.reset({}, { keepEpoch: false, purgeLocalStorage: false });
const validateResults = await db.validate({ noDev: false });Dev migrations are ephemeral migration files used during development for fast iteration.
- Created by:
db push - Location:
node_modules/.cache/durable-db/databases/<dbName>/migrations/ - Naming: Content-hash based -- a single squashed
dev_<hash>.sqlthat is replaced (not appended to) on every push with changes. The deterministic name lets the DO skip migrations it has already applied - Lifecycle: Cleared when you run
db generate(consolidated into production) ordb reset - Never committed to git
The Vite plugin automatically loads dev migrations in dev mode and appends them after production migrations when generating the DO class.
Production migrations are the canonical migrations committed to your repository.
- Created by:
db generate - Location: The
migrationsDirconfigured in the Vite plugin (e.g.migrations/main/) - Naming: Timestamp-based --
20240315123045.sqlor20240315123045_description.sql - Lifecycle: Permanent, committed to git, deployed to production
- Snapshot: Each
generatealso updates_snapshot.jsonin the migrations directory (tracks schema state withid/prevIdchain)
Long migrations can be split into chunks using the --> breakpoint marker:
CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL);
--> breakpoint
CREATE INDEX idx_users_name ON users(name);
--> breakpoint
CREATE TABLE posts (id TEXT PRIMARY KEY, author_id TEXT REFERENCES users(id));Each chunk is tracked independently in the __migrations table. If a Durable Object restarts mid-migration, it resumes from the last completed chunk rather than re-running from the start.
In development, each database instance key gets an epoch suffix to enable clean resets:
| Environment | Instance key |
|---|---|
| Production | example-tenant |
| Development | example-tenant__dev_m1a2b3c |
When you run db reset (without --keep-epoch), a new epoch is generated. This causes all subsequent DO accesses to create fresh instances, effectively giving you a clean database without data from previous iterations.
The epoch is a base36-encoded timestamp stored in node_modules/.cache/durable-db/state.json.
How the epoch reaches the worker: the Vite plugin serves a virtual module (virtual:durable-db/__devEpoch) exporting applyDevEpoch(key), and every generated stub routes its instance key through it. In dev the plugin embeds the current epoch from state.json; in production builds the epoch is null and applyDevEpoch is the identity function, so production keys are never suffixed. The dev server watches durable-db's dev cache directory and reloads on change, so both db reset (new epoch → fresh instances) and db push (new dev migration → re-embedded into the DO module) take effect on the next request without restarting.
node_modules/.cache/durable-db/
state.json # Global state (epoch, per-db push info)
databases/
main/
_snapshot.json # Dev snapshot (schema state)
migrations/
dev_a1b2c3d4.sql # Single squashed dev migration (content-hash name)
analytics/
_snapshot.json
migrations/
dev_e5f6a7b8.sql
The state.json file tracks:
{
"epoch": "m1a2b3c",
"databases": {
"main": {
"prodSnapshotHash": "abc123...",
"lastPush": "2024-03-15T10:30:00.000Z",
"devMigrationCount": 2
}
}
}During development:
# 1. Edit your Drizzle schema
# 2. Push changes to dev migrations
db push
# 3. Run the dev server -- migrations apply automatically on DO access
pnpm dev
# 4. Iterate: edit schema -> push -> refresh browser
# 5. If you need a clean slate (works while the dev server is running):
db resetReady to deploy:
# 1. Generate a production migration
db generate add_user_profiles
# 2. Validate before deploying
db validate
# 3. Commit the migration file and updated snapshot
git add migrations/
git commit -m "Add user profiles migration"
# 4. DeployTeam collaboration:
db push always diffs against the current production snapshot, so after pulling a teammate's migration your next push rebuilds the squashed dev migration on top of the new baseline automatically. If your local DO instances already applied an outdated dev migration, run db reset to rotate to fresh instances — db status warns with ⚠️ Production snapshot changed when this applies.
import { durableDb } from 'durable-db/vite';
durableDb({
databasesDir: 'src/databases', // Where database files live
migrationsDir: 'migrations', // Where production migrations live
});| Option | Type | Default | Description |
|---|---|---|---|
databasesDir |
string |
'src/databases' |
Directory containing database definition files |
migrationsDir |
string |
'migrations' |
Directory for production migrations, relative to project root. Each database gets a subdirectory (e.g. migrations/main/) |
contextImport |
string |
'durable-db/context' |
Import path for the context module (for framework integrations) |
registryImport |
string |
'durable-db/registry' |
Import path for the action registry module (for framework integrations) |
patchWranglerConfig |
boolean |
false |
Write missing DO bindings and sqlite migration entries to wrangler.jsonc automatically. Off by default — the plugin only verifies the config and logs the exact JSON to add. Only wrangler.jsonc/wrangler.json are supported (not wrangler.toml) |
- Discovery: Finds all
defineDatabase()files in your databases directory (excludesschema.ts,_*.ts,.d.ts) - AST Parsing: Uses Babel to extract database config and action definitions (no regexes)
- Migration Loading: Loads production migrations from disk; in dev mode also loads dev migrations from cache
- Code Generation: Produces a virtual module (
virtual:durable-db/__durableObjects) containing Durable Object classes with embedded migrations and RPC dispatch methods, plus avirtual:durable-db/__devEpochmodule that suffixes DO instance keys with the dev epoch (identity in production builds) - Action Transform: Replaces
action()call-sites with RPC stubs +registerAction()calls so actions can be called like regular functions from your worker. This applies to actions in separate files and actions defined in the database file itself - Wrangler Verification: Checks that
wrangler.jsonccontains the required Durable Object bindings and SQLite migration entries, and logs the exact JSON to add when something is missing. WithpatchWranglerConfig: true, writes them for you instead - HMR: Watches database files and invalidates the virtual module on change
The plugin needs each database's Durable Object binding and a new_sqlite_classes migration entry in your wrangler config.
Default (verify-only): your config is never modified. On startup the plugin checks for missing entries and logs the exact JSON to add, e.g.:
[durable-db] wrangler.jsonc is missing Durable Object config for your database(s). Add this:
{
"durable_objects": { "bindings": [{ "name": "MAIN_DATABASE_DO", "class_name": "MainDatabaseDO" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["MainDatabaseDO"] }]
}
Or let the plugin write it for you: durableDb({ patchWranglerConfig: true })
Opt-in (auto-patch): pass durableDb({ patchWranglerConfig: true }) and the plugin writes the missing entries for you. If your wrangler.jsonc contains comments, the original is backed up to wrangler.jsonc.backup first (re-serializing strips comments).
⚠️ Onlywrangler.jsonc/wrangler.jsonare supported. If your project useswrangler.toml, the plugin can't verify or patch it — it will print the required config and you add the TOML equivalent yourself.
For each defineDatabase() call, the plugin generates a class based on the filename:
| Filename | Class Name | Binding Name |
|---|---|---|
main.ts |
MainDatabaseDO |
MAIN_DATABASE_DO |
analytics.ts |
AnalyticsDatabaseDO |
ANALYTICS_DATABASE_DO |
user-data.ts |
UserDataDatabaseDO |
USER_DATA_DATABASE_DO |
Export the generated classes from your worker entry point:
export * from 'virtual:durable-db/__durableObjects';The plugin transforms each action() definition into two things:
- A registry registration (handler + ArkType validator) -- runs inside the DO
- An RPC stub function (exported under the same name) -- runs in your worker
The stub function:
- Validates args with ArkType
- Checks if we're already inside the same DO (via AsyncLocalStorage) for a fast direct-call path
- Computes the instance key (
getTenantId()or"global") and routes it throughapplyDevEpoch()(dev-epoch suffix in dev, identity in production — see Epoch System) - Otherwise, resolves the DO instance via
env.BINDING.idFromName(instanceKey)and callsstub.rpc()
Outerbase Studio provides a web UI and SQL endpoint for inspecting SQLite tables inside Durable Objects.
Add browsable to your defineDatabase() config:
export const { action } = defineDatabase({
schema: { users, posts },
browsable: 'development', // Enable in dev mode only
});| Value | Behavior |
|---|---|
false |
Disabled (default) |
true |
Always enabled (dev and production) |
'development' |
Enabled only when running vite dev / vite serve |
When browsable is enabled, the Vite plugin wraps the generated Durable Object class with Outerbase's Browsable() decorator. This adds:
- A
fetch()handler that serves/query/rawfor direct SQL access - A
__studio()RPC method used by the Outerbase Studio web UI
The 'development' value is resolved at build time: the Vite plugin checks config.command === 'serve' and only applies the decorator when running in dev mode.
Migrations are guaranteed to run before any browsable request — the generated class overrides both fetch() and __studio() to call ensureMigrations() first.
To serve the Outerbase Studio web interface, add a route in your worker that calls the studio() helper:
import { studio } from 'durable-db/db';
export default {
async fetch(request: Request, env: any) {
const url = new URL(request.url);
if (url.pathname === '/studio') {
return studio(request, env.MAIN_DATABASE_DO);
}
// ... rest of your routes
},
};Then visit /studio in your browser. You'll be prompted to enter a DO instance name (e.g. your tenant ID), and Outerbase Studio will open with full SQL access to that instance.
You can also query a DO's SQL endpoint directly without the Studio UI:
curl -X POST http://your-do-endpoint/query/raw \
-H 'Content-Type: application/json' \
-d '{"sql": "SELECT * FROM users LIMIT 10"}'The browsable endpoint has no built-in authentication. For production use (browsable: true), add your own authentication middleware or restrict access at the network level.
Using browsable: 'development' is recommended -- it enables the endpoint only during local development and excludes it from production builds entirely.
Each tenant gets its own Durable Object instance, keyed by the tenant ID provided via setTenantIdResolver().
defineDatabase({
schema: { users },
instance: 'per-tenant',
});A single shared Durable Object instance for all requests, keyed by the string 'global'.
defineDatabase({
schema: { settings },
instance: 'global',
});Actions can call other actions. The Vite plugin detects these calls at build time and routes them correctly.
// actions/createUser.ts
import { action } from '../main';
import { getUserByEmail } from './getUserByEmail';
export const createUser = action({
args: { name: 'string', email: 'string.email' },
handler: async (db, args) => {
// This calls another action in the same database
const existing = await getUserByEmail({ email: args.email });
if (existing) throw new Error('User already exists');
return db.insertInto('users').values({
id: crypto.randomUUID(),
name: args.name,
email: args.email,
createdAt: new Date(),
}).returningAll().executeTakeFirstOrThrow();
},
});- Same database: The call uses a direct fast path via AsyncLocalStorage (no RPC overhead). The registry detects that we're already inside the target DO and calls the handler directly.
- Cross database: The call routes through RPC to the other database's Durable Object, using the appropriate instance key based on the target database's strategy.
On Cloudflare with Point-in-Time Recovery (PITR) enabled, migrations are protected with automatic snapshots.
- Before running migrations, the DO increments a retry counter and takes a PITR bookmark
- Migrations are applied (this is the second write, after the counter increment)
- On success: the retry counter is reset to 0
- On failure: the DO schedules a restore to the pre-migration bookmark and aborts
- On next access, the DO restarts with the counter already incremented (the counter write was before the bookmark)
- After 3 consecutive failures, PITR restore is skipped and the error propagates so the developer can fix the migration and redeploy
This prevents broken migrations from permanently corrupting data while giving developers a clear signal to fix the issue.
The SqliteDurableObject base class provides methods for inspecting migration state:
getMigrationAttempts()-- Returns{ attemptCount, lastAttemptAt, lastError }getMigrationBookmark()-- Returns the current PITR bookmark string, ornullif PITR is unavailablerestoreToBookmark(bookmark)-- Manually trigger a PITR restore
This section covers what to do when things go wrong. The goal is to give you a clear playbook for the common failure modes instead of guessing.
After 3 consecutive failed migration attempts, PITR restore is skipped and the error surfaces on every request. The DO will log:
[database] Migration has failed 3 times. PITR restore disabled -- fix the migration and redeploy.
Do this:
- Inspect current state with
getMigrationStatus()on the DO -- it returns{ attempts, pending, applied, pitrAvailable, pitrAttemptsRemaining }, so you can see exactly which migration is stuck and how many PITR retries are left. (getMigrationAttempts()still exists if you only need the attempts counter.) - Fix the offending
.sqlfile inmigrations/<db>/. Typical culprits: non-nullable column added without a default, aDROP COLUMNon a table with data, an index name collision. - Redeploy. The next request will attempt the (now fixed) migration against the pre-migration state -- no manual reset needed.
- If you're certain the DO is already in a bad state (e.g. a migration was half-applied before the bookmark logic landed), call
restoreToBookmark(bookmark)manually from a worker route with a known-good bookmark, then redeploy.
Don't try to delete or rewrite a migration that's already been applied in production -- it'll be skipped on machines that already ran it and break machines that haven't. Always forward-fix with a new migration.
In development, migrations run against ephemeral state cached under node_modules/.cache/durable-db/. Use whichever matches your situation:
- Reset one tenant's data while keeping schema: call
destroyDatabase()from a route. This runsctx.storage.deleteAll()on the current tenant's DO and re-runs migrations on the next call. - Reset every tenant's data for a database: run
pnpm db reset(orpnpm db reset --database main) — the dev server can keep running. Instance keys get a new__dev_<epoch>suffix, so every tenant rotates to a brand-new DO with empty SQLite on the next request. Add--purge-local-storage(dev server stopped) when you also want the orphaned instances' files deleted from.wrangler/. - Drop everything and re-derive migrations from the schema:
rm -rf node_modules/.cache/durable-dband restartpnpm dev. The plugin regenerates dev migrations from the current schema.
The _snapshot.json file in each migrations/<db>/ directory is the source of truth for what schema the migration history represents. If it's been hand-edited, merged badly, or lost:
pnpm db validate-- this will compare your live schema against what the snapshot claims and flag drift.- If only the snapshot is missing but the
.sqlfiles are correct, delete_snapshot.jsonand runpnpm db generate. It will reconstruct the snapshot by replaying migration history. - If both
_snapshot.jsonand an SQL file are out of sync: checkgit log -- migrations/<db>/for the last known-good revision and restore from there. Never regenerate by hand -- letdb generateproduce the diff.
These errors now include the list of available actions/bindings so you can see what is wired up. Typical causes:
- Action not registered: the action file didn't match the Vite plugin's discovery rules (see the Vite Plugin section). Files named
schema.ts,_*.ts, or*.d.tsare excluded. - Missing binding: the target database
.tsfile isn't indatabasesDir, or the wrangler config wasn't patched (checkwrangler.jsoncfor adurable_objects.bindingsentry). A full Vite restart regenerates bindings.
Pending requests time out after 30s by default and reject with WebSocket request '<action>' timed out after 30000ms. If you're seeing timeouts:
- Confirm the target DO's action handler actually returns (look for unawaited promises or an uncaught exception in logs).
- If the action is legitimately slow, pass
new WebSocketTransport(stub, { requestTimeoutMs: 60_000 })when constructing manually, or switch that database totransport: 'rpc'which has no intrinsic timeout beyond the platform's request limit.
| Export path | Source | Purpose |
|---|---|---|
./db |
src/db/ |
defineDatabase(), SqliteDurableObject, Kysely plugins |
./vite |
src/vite/durableDb.ts |
Vite plugin (durableDb) |
./vite/modules |
src/vite/modules/ |
Plugin internals: discovery, AST parsing, code generation, wrangler patching |
./context |
src/context/ |
Tenant ID context (setTenantIdResolver, getTenantId) |
./migrations |
src/migrations/ |
Snapshot-based migration generation via drizzle-kit |
./registry |
src/registry.ts |
Action registry and RPC dispatch (registerAction, getAction, callAction) |
./schema |
src/schema.ts |
Schema builders: table() (auto-snake_case wrapper around Drizzle's sqliteTable), text, integer, etc. |
./cli |
src/cli/ |
CLI commands (push, generate, status, reset, validate) and db binary |
setTenantIdResolver(...) // Configure once at startup
Worker fetch()
-> createUser({ name, email }) // Looks like a normal function call
-> ArkType validates args
-> getTenantId() // Calls resolver → throw if unset
-> Check DO context for direct-call short path
-> If same DO: direct handler call (no RPC)
-> If cross-DO: env.BINDING.idFromName(instanceKey) -> stub.rpc()
-> DO.rpc(method, args, rpcContext)
-> ensureMigrations() // Run pending migrations if any
-> getAction(dbName, method) // Look up handler in registry
-> Validate args with ArkType
-> runWithDoContext(...) // Set up DO-local context
-> handler(db, validatedArgs, ctx) // Your action code runs here
The library includes plugins for transparent data mapping between JavaScript and SQLite:
- DrizzleDefaultsPlugin: Auto-populates columns with Drizzle's
$defaultFn()on INSERT (e.g. auto-generated IDs,createdAttimestamps) and$onUpdateFn()on UPDATE (e.g.updatedAttimestamps). Columns that are explicitly provided in the query are not overridden. - SchemaPlugin: Schema-aware extension of Kysely's
CamelCasePlugin. Maps camelCase JS property names to snake_case SQL names for both tables and columns using Drizzle schema metadata. Falls back to standard CamelCasePlugin behavior for names not in the schema. - DateSerializePlugin: Converts
Dateobjects toYYYY-MM-DD HH:MM:SSstrings for SQLite storage, and parses them back intoDateobjects on read. The read path is deliberately conservative: only values in exactly the format the write path produces (also what SQLite'sCURRENT_TIMESTAMPemits) are converted, and — when constructed with a schema — only for columns Drizzle declares as date-typed (e.g.integer({ mode: 'timestamp' })). User-stored strings intext()columns (ISO strings with aTseparator, timezone, or milliseconds) round-trip verbatim.
All three plugins are automatically configured when using createDrizzlePlugins(schema).
pnpm install # Install dependencies
pnpm build # Build with tsdown -> dist/
pnpm dev # Build in watch mode
pnpm test # Run vitest in watch mode
pnpm test:run # Run tests onceRun a single test file:
npx vitest run tests/db/defineDatabase.test.tsRun the example apps:
cd examples/simple && pnpm dev
cd examples/rwsdk && pnpm devsrc/
cli/ # CLI commands and state management
bin.ts # Binary entry point (commander)
push.ts # Push command implementation
generate.ts # Generate command implementation
status.ts # Status command implementation
reset.ts # Reset command implementation
validate.ts # Validate command implementation
state.ts # Dev state persistence (epoch, snapshots, counters)
context/ # Tenant ID resolver context
db/ # Core database abstractions
SqliteDurableObject.ts # Base DO class with migrations + PITR
defineDatabase.ts # defineDatabase() API
plugins.ts # Kysely plugins (DrizzleDefaults, Schema, DateSerialize)
types.ts # TypeScript type definitions
migrations/ # Snapshot-based migration generation
snapshot.ts # Drizzle schema -> snapshot diffing
generator.ts # Migration file reading/writing
registry.ts # Action registration and RPC dispatch
vite/
durableDb.ts # Vite plugin entry
modules/
discovery.ts # Database file discovery
parser.ts # Babel AST parsing
generator.ts # DO class and stub code generation
wrangler.ts # wrangler.jsonc auto-patching
tests/ # Mirrors src/ structure
examples/
simple/ # Basic Cloudflare Worker example
rwsdk/ # Multi-tenant analytics API with RWSDK + WebSocket transport