Package Exports
- kysely-durable-objects
Readme
kysely-durable-objects
Kysely dialect for Cloudflare Durable Object SQLite storage (ctx.storage.sql). A fully tested, battle-ready dialect that respects the Durable Object runtime's constraints.
Highlights
- Comprehensively tested in real
workerd. 30+ tests run inside the same C++ runtime Cloudflare deploys, against realctx.storage.sql. CI exercises the suite across a matrix ofcompatibility_datevalues (DO-SQLite GA → today) so silent API drift in the runtime can't sneak in. A production-parity guard re-runs the full CRUD path withnew Function()patched to throw, proving the dialect doesn't rely on dynamic code generation. insertIdandnumAffectedRowswork end-to-end. The dialect querieslast_insert_rowid()andchanges()after each mutation, so Kysely'sRETURNING,numAffectedRows, and MikroORM identity tracking all behave correctly.BigIntparameter binding. DO's storage layer rejectsbigintat the binding boundary. The dialect transparently stringifies bigints so SQLite parses them as native 64-bitINTEGERwithout truncation.- Honors DO atomicity semantics.
db.transaction()throws an actionable error pointing to the bundledwithDoTransactionhelper, which wrapsctx.storage.transactionSync(). The dialect never silently swallows aBEGIN— that would turn rollback into a corruption hazard. - MikroORM compatible.
clone()returns the same instance so MikroORM'sUtils.copy()deep-clone ofdriverOptionspreserves the closure aroundctx.storage.sql. - Real error paths. UNIQUE/NOT NULL violations and SQL syntax errors surface as real exceptions, tested in real workerd.
UPSERT(ON CONFLICT DO UPDATE) withRETURNINGworks end-to-end.
Install
npm install kysely-durable-objects kyselykysely is a peer dependency.
Usage
Kysely
import { Kysely } from 'kysely';
import { DurableObjectSqliteDialect } from 'kysely-durable-objects';
import { DurableObject } from 'cloudflare:workers';
interface Schema {
users: { id: number; name: string; email: string };
}
export class MyDO extends DurableObject {
private db: Kysely<Schema>;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.db = new Kysely<Schema>({
dialect: new DurableObjectSqliteDialect(ctx.storage.sql),
});
}
}MikroORM
MikroORM v7 uses Kysely under the hood and accepts any Kysely dialect via driverOptions.
import { MikroORM } from '@mikro-orm/core';
import { SqliteDriver } from '@mikro-orm/sql';
import { DurableObjectSqliteDialect } from 'kysely-durable-objects';
import compiledFunctions from './compiled-functions.js';
ctx.blockConcurrencyWhile(async () => {
this.orm = await MikroORM.init({
driver: SqliteDriver,
dbName: 'do',
driverOptions: new DurableObjectSqliteDialect(ctx.storage.sql),
entities: [...],
implicitTransactions: false, // required: BEGIN is blocked in DOs
compiledFunctions, // required: new Function() is blocked in DOs
});
});Pre-generate compiled functions with npx mikro-orm compile. See the MikroORM deployment docs.
Atomicity (withDoTransaction)
db.transaction() throws on this dialect — there is no safe way to bridge Kysely's async stepwise BEGIN/COMMIT/ROLLBACK lifecycle to DO's synchronous transactionSync(closure) primitive. For atomic blocks, use the bundled helper:
import { withDoTransaction } from 'kysely-durable-objects';
withDoTransaction(ctx.storage, (sql) => {
sql.exec('update accounts set balance = balance - ? where id = ?', 100, fromId);
sql.exec('update accounts set balance = balance + ? where id = ?', 100, toId);
});Throwing inside the closure rolls back. The closure is synchronous — that's a hard constraint of transactionSync.
Tested in real workerd
30+ tests, all running inside the same C++ runtime Cloudflare deploys (via @cloudflare/vitest-pool-workers), against real ctx.storage.sql:
- CRUD & isolation — schema builder, INSERT/SELECT/UPDATE/DELETE, RETURNING, auto-increment, per-DO storage isolation,
runInDurableObjectintrospection,destroy()semantics. - Eval-guarded CRUD — full CRUD path re-run with
Function/evalpatched to throw, simulating the productionnew Function()ban that the local runner relaxes. - Error paths — UNIQUE/NOT NULL constraint violations and SQL syntax errors surface as real exceptions.
UPSERT—INSERT ... ON CONFLICT DO UPDATEwithRETURNING.withDoTransaction— commit and rollback semantics under realtransactionSync.- Kysely Migrator end-to-end — runs real migrations, verifies the
kysely_migrationtracking table populates correctly, and that the target schema lands. - Type fidelity —
NULLacross all column affinities,BLOB(Uint8Array) bit-exact roundtrip,BigIntwithin and beyond JS safe-integer range,REALprecision, dates as integer ms, JSON text +json_extract(). - Concurrency — reproduces the
await-boundary lost-update race and provesblockConcurrencyWhilemitigates it.
CI runs the full suite across a matrix of compatibility_date values, so any silent change to DO storage or Workers runtime behavior between releases is caught.
npm testAPI
new DurableObjectSqliteDialect(sql: SqlStorage): Dialect
withDoTransaction<T>(
storage: { sql: SqlStorage; transactionSync<T>(fn: () => T): T },
closure: (sql: SqlStorage) => T,
): TType re-exports for convenience: SqlStorage, SqlStorageCursor, DurableObjectStorageLike — mirror @cloudflare/workers-types so this package has no hard dependency on it.
Migrations
Kysely's built-in Migrator works against this dialect end-to-end. The kysely_migration and kysely_migration_lock tables are created automatically on first run; the lock table is semantically redundant inside a DO (per-instance serialization already prevents concurrent migration), but harmless.
Platform limitations
Properties of the Durable Object runtime that consumers need to plan around. The dialect surfaces these honestly rather than hiding them.
new Function()/eval()are banned during request handling. The dialect's hot path is eval-free; if you use MikroORM, configurecompiledFunctionsto ship pre-compiled query plans.- No raw
BEGIN/COMMIT/ROLLBACK/SAVEPOINT. DO storage exposes atomicity only viactx.storage.transactionSync(closure). UsewithDoTransaction(above). INTEGERcolumns return as JSNumber.BigIntvalues up to 2^53 roundtrip bit-exactly. Beyond that, the value lands in storage intact (the dialect coerces on write) but a direct read rounds. Recover the full 64-bit value withCAST(col AS TEXT):SELECT CAST(big_id AS TEXT) AS big_id_str FROM ledger WHERE ...changes()andlast_insert_rowid()aren't returned byexec(). The dialect issues two extraSELECTcalls after each mutation to retrieve them. Safe — storage operations inside a DO are serialized.- Async workflows can interleave at
awaitboundaries. Per-instance serialization is at the storage-operation level, not at the application code level. For read-modify-write across awaits, wrap withctx.blockConcurrencyWhile.
TODO
- Production-environment smoke tests — opt-in suite that deploys to a real Cloudflare account and runs the test matrix against the actual production runtime, surfacing any divergences from local
workerd. - Streaming queries —
db.selectFrom(...).stream()(Kysely's user-facing streaming API) isn't directly tested. - Examples directory — a runnable mini-Worker showing the dialect end-to-end (
git clone && wrangler dev). - Compatibility matrix in README — explicit Kysely / MikroORM versions tested against.
License
MIT