This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
durable-db — a zero-configuration database abstraction for Cloudflare Durable Objects with SQLite. Uses Drizzle for schema definition, Kysely for queries, and ArkType for runtime validation. A Vite plugin handles build-time code generation (Durable Object classes, RPC stubs, migration embedding, wrangler config verification with opt-in patching).
pnpm build # Build with tsdown → dist/
pnpm dev # Build in watch mode
pnpm test # Run vitest in watch mode
pnpm test:run # Run tests once
pnpm changeset # Create a changeset for version bumpingRun a single test file:
npx vitest run tests/db/defineDatabase.test.tsRun the example apps:
cd examples/simple && pnpm dev
cd examples/rwsdk && pnpm devpnpm workspace with examples/* as separate packages. The root package is the library itself. Published to npm (as durable-db) via changesets.
| Export path | Source | Purpose |
|---|---|---|
./db |
src/db/ |
defineDatabase() API, SqliteDurableObject base class, Kysely plugins |
./vite |
src/vite/durableDb.ts |
Vite plugin entry (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 name → handler registry for RPC dispatch |
./cli |
src/cli/ |
CLI commands (push, generate, status, reset, validate) and db binary |
- User defines Drizzle schema in
src/databases/schema.tsand database + actions insrc/databases/*.ts - Vite plugin discovers database files, parses them with Babel AST, extracts
defineDatabase()calls and action exports - Code generation produces a virtual module (
virtual:durable-db/__durableObjects) containing Durable Object classes with embedded migrations and action methods - Action call transformation: internal calls → direct handler call (no RPC), cross-DB calls → RPC via
ctx.env - RPC stubs are generated so workers can call actions like regular async functions
- wrangler.jsonc is verified to contain the Durable Object bindings and no stale ones. In dev, missing or stale entries are logged with the JSON to add or remove; production builds fail hard on them (wrangler.toml projects only warn since the check can't parse toml). Reconciliation is opt-in via
patchWranglerConfig: trueand runs in the plugin'sconfighook (syncWranglerConfiginsrc/vite/modules/syncWrangler.ts, also exported fromdurable-db/vite), before@cloudflare/vite-plugin'sconfighook reads the file — that plugin emits the build'swrangler.jsonfrom its copy and restarts a dev server on a later change, so patching lazily (load/transform) shipped stale bindings and restarted the first dev start.syncWranglerConfigdiscovers the databases (with theirmigrationsDir, which a rename moves) and writes nothing while a database file does not parse (a partial set reads as databases removed);reconcileWranglerConfigpatches and logs one summary line built from thePatchResult(durable-db updated wrangler.jsonc (…)); an app without databases is left alone unless its config still binds durable-db classes. A refused rename (conflicts) throws (stopOnRenameConflicts), in the hook already.initialize()reconciles again only when the set of databases differs from the one last reconciled (wranglerReconciledFor): a database added or remounted while the dev server runs, or the plugin driven without the hook.patchWranglerConfig()(src/vite/modules/wrangler.ts, in-place JSONC edits, comments and layout kept) adds bindings andnew_sqlite_classes, removes durable-db's own bindings of databases that are gone, writesrenamed_classesfor a mounted database whose class changed (exact file stem, unique pairing, never for unprefixed databases) and moves its migrations directory with it, refuses when the migrations cannot follow, and never writesdeleted_classes(a note gives the entry; not a build failure). Tombstones: every class of durable-db's naming the migrations still declare live (declaredClasses: created or arenamed_classestarget, not deleted or renamed away) but no discovered database defines is exported by the generated module asclass <X>DatabaseDO extends RemovedDatabaseDurableObject(tombstoneClasses()inwrangler.ts, read at load, after theconfighook;src/db/RemovedDatabaseDurableObject.ts): storage untouched,fetch→ 410,rpc/sysreject withRemovedDatabaseErrornaming thedeleted_classesentry. @cloudflare/vite-plugin wraps every class the config names (workerd:Class extends value undefinedotherwise) and a deploy that drops a class with data fails, so a removed database (or extension) must not take its class with it
schema: Drizzle schema tablesinstance:'per-tenant'(default) or'global'transport:'rpc'(default) or'websocket'browsable:false(default),true, or'development'for Outerbase Studio
databasesDir: directory containing database files (default:'src/databases')migrationsDir: directory for production migrations, relative to project root (default:'migrations'). Each database gets a subdirectory (e.g.migrations/main/)contextImport/registryImport: override import paths for framework integrationspatchWranglerConfig: reconcile wrangler.jsonc with the discovered databases, in theconfighook (default:false— verify only: warn in dev, fail production builds). Only wrangler.jsonc/json supported, not wrangler.toml (toml never fails the build)
destroyDatabase is an optional function returned by defineDatabase(). It calls ctx.storage.deleteAll() inside the Durable Object, which atomically wipes the entire SQLite database (including Cloudflare internal metadata that counts toward billable storage). The next action call re-runs migrations and starts fresh.
export const { action, destroyDatabase } = defineDatabase({
schema: { users, posts },
});
// In a route handler:
await destroyDatabase(); // clears current tenant's database- Opt-in: only available if explicitly destructured from
defineDatabase() - For
per-tenantdatabases: targets the current tenant (viagetTenantId()) - For
globaldatabases: targets the single shared instance - The Vite plugin detects
destroyDatabasein the destructuring pattern and generates an RPC stub that callsstub.sys("destroyDatabase")on the DO - Generated DO classes have a
sys(command)method (separate fromrpc()) that handles system operations
Actions are defined with action({ args, handler }). The args object uses ArkType syntax for runtime validation, compiled with actionArgsType (src/db/actionArgs.ts, exported as durable-db/registry's type): a jitless scope with exactOptionalPropertyTypes: false, so an optional key accepts undefined as its inferred type does. The handler receives (db: Kysely, args, ctx). Actions can call other actions — the Vite plugin detects these at build time via Babel AST traversal and transforms them appropriately.
- Schema snapshots stored in
_snapshot.jsonin the migrations directory - Dev migrations: ephemeral, stored in
node_modules/.cache/durable-db/ - Production migrations:
.sqlfiles in the Vite plugin'smigrationsDir(default:migrations/<dbName>/), created viadb generate - Dev epoch suffixing allows resetting local databases without conflicts
- Dev statement journal (
__dev_migration_statements, dev storage only): a pending dev migration skips the statements of an earlier dev migration it contains whole (db pushre-squashes from the production snapshot), and a pending production migration whose statements dev migrations already ran is recorded, not executed (db pushthendb generate). - Runtime failure modes (
SqliteDurableObject): a pending migration failing with "already exists" or "duplicate column name" raisesMigrationSchemaConflictError(storage/journal drift from renamed or regenerated migration files) with recovery guidance namingdb reset --database <databaseName>(the generated DO setsdatabaseName). A PITR restore rejected as unsupported (local workerd implementsgetCurrentBookmarkbut not the restore call) disables PITR for the instance at debug level instead of logging an error. Concurrent first calls share one migration run (ensureMigrationskeeps the in-flight promise). - Queries quote identifiers with backticks (
BacktickSqliteQueryCompiler): SQLite reads a double-quoted identifier that names no column as a string literal, so a column missing from the database would read back as its own name. - Testing (
durable-db/testing):createTestDatabase()also publishes a live test database, so code under test that calls an action directly runs it there. - Schema loading (
loadSchemainsrc/cli/shared.ts) is strict: declared-but-unloadable tables (inline definitions, unresolvable imports, missing exports, build failures) throw instead of silently producing "no changes". Only databases with zero declared tables are skipped. - Dev epoch wiring: generated stubs route every instance key through
applyDevEpoch(key, dbName)from the virtual modulevirtual:durable-db/__devEpoch. Epochs are per database:state.jsonhas the projectepochanddatabases[name].epoch(set bydb reset --database <name>, which bumps only that one; a full reset sets a new project epoch and drops the per-database ones;generatekeeps them —forgetDatabaseDevState). In dev the Vite plugin embeds both (devEpoch,devEpochs); in production builds the epoch is null and the function is the identity. The key format stays<key>__dev_<epoch>(getTenantId()inside a DO strips it,global__dev_*has no tenant). The dev server watches the whole dev cache dir and fully re-initializes plugin state on change, sodb reset(epoch bump → fresh DO instances) anddb push(new squashed dev migration → re-embedded) both work live.--purge-local-storageoptionally deletes the orphaned instances under.wrangler/state/v3/do; it refuses while the dev server runs (--forceoverrides):configureServerwritesdev-server.json(pid + token) into the dev cache dir and removes its own on close; a lock whose pid is dead is ignored. The cache watcher ignores the lock and the late-reported creation of the cache dir itself. --database <name>on every command (push, generate, status, reset, validate) goes throughselectDatabases()(src/cli/shared.ts): an unknown name throwsUnknownDatabaseErrorlisting the valid names (exit 1) — never "No databases found." / a reset of a name that isn't there.- Renames: drizzle-kit's diff asks "created or renamed?" (TTY prompts) whenever a table has both a new and a vanished column (or the schema a new and a vanished table); its API takes no answers.
generateMigrationStatements(from, to, { renames, noRenames, interactive })(src/migrations/snapshot.ts+renames.ts) resolves before drizzle-kit sees it:--rename t.old=new/old=newanswers are applied to the previous snapshot (references follow) and emitted as drizzle-kit's ownALTER TABLE … RENAMEstatements;--no-renamessplits the diff into drops then additions (withoutRemoved), neither of which asks; otherwise, without a TTY (orinteractive: false),SchemaRenameRequiredErrornames the questions and both answers (describe(command)— the CLI passes the command path as typed, e.g.lagerverk db generate).statusnever prompts and reports "needs rename resolution". push/generate diff every database first (diffDatabases) so a refusal writes nothing; an answer that matches no pending change is an error. - Risks (
src/migrations/risks.ts,analyzeMigrationRisksover the snapshots, renames read back from the statements so interactive answers count): a column added NOT NULL without a default fails on every table with rows —generaterefuses it (UnsafeMigrationError,--forcewrites it); dropped columns/tables and columns made NOT NULL are warnings.pushandstatusshow them,validatereports them for the pending (not yet generated) change only — its replay runs on an empty database and can't know which committed migrations ran where.
per-tenant(default): separate DO instance per tenant, keyed by tenant IDglobal: single shared DO instance
Schemas are defined using table() from durable-db/schema (not sqliteTable from drizzle-orm directly). The table() wrapper auto-converts table names to snake_case. Column names are omitted — they are derived from the JS property key and auto-snake_cased by the migration system (casing: 'snake_case').
import { table, text, integer } from 'durable-db/schema';
export const userProfiles = table('userProfiles', {
id: text().primaryKey(),
displayName: text().notNull(),
priceInCents: integer().notNull(),
createdAt: integer({ mode: 'timestamp' }).notNull(),
});
// SQL: user_profiles (display_name, price_in_cents, created_at)- DrizzleDefaultsPlugin: auto-populates columns with
$defaultFn()on INSERT and$onUpdateFn()on UPDATE - SchemaPlugin (extends CamelCasePlugin): schema-aware camelCase ↔ snake_case for both table and column names, falls back to standard CamelCasePlugin
- DateSerializePlugin: handles Date serialization/deserialization for SQLite. Writes Dates as
YYYY-MM-DD HH:MM:SS; the read path only converts values in exactly that format back toDate(and, when constructed with a schema, only for Drizzle date-typed columns). User-stored ISO strings intext()columns round-trip verbatim — date-ish data should useinteger({ mode: 'timestamp' }).
- Database definition files live in
src/databases/. Files namedschema.ts,_*.ts, and.d.tsare excluded from action discovery. - Actions can be defined inline in the database file or in separate files in the same directory. Both are transformed identically:
transformDatabaseFilehandles same-file actions (plusdestroyDatabase),transformActionFilehandles separate files. - Schema tables must be defined in a schema module and imported into the database file — inline table definitions can't be loaded by the migration CLI and fail loudly.
- The Vite plugin uses Babel (not regex) for all source code analysis.
transformfirst applies a cheap superset check (mayDefineDatabaseCode: mentionsdefineDatabase/destroyDatabase, or has an import declaration with anactionspecifier in any clause form —import main, { action },type, comments, line breaks; it must admit everything the parser takes an action factory from) so unrelated modules are never Babel-parsed — a syntax error in a page is reported by the compiler that owns it, not as[plugin:durable-db]. viteis a peer dependency, 5 through 8.- Build output is ESM-only via tsdown.
- Tests mirror source structure under
tests/.