Skip to content

feat: atomic transactions for Cloudflare Durable Objects SQLite + tx.sql (#140)#142

Merged
pdlug merged 1 commit into
mainfrom
feat/do-sqlite-transactions
May 16, 2026
Merged

feat: atomic transactions for Cloudflare Durable Objects SQLite + tx.sql (#140)#142
pdlug merged 1 commit into
mainfrom
feat/do-sqlite-transactions

Conversation

@pdlug
Copy link
Copy Markdown
Contributor

@pdlug pdlug commented May 16, 2026

Closes #140.

What ships

Cloudflare Durable Objects SQLite is now fully transactional. A store backed by drizzle(ctx.storage) is auto-detected as a new SQLite transactionMode: "do-sqlite" and reports capabilities.transactions: true (previously false). store.transaction() and store.withTransaction() roll back and commit atomically by delegating to the async ctx.storage.transaction(async …) runner (Drizzle's db.$client.transaction), which rolls back across await. Drizzle's own db.transaction() on DO is ctx.storage.transactionSync and cannot span an await, so it is deliberately not used.

This also fixes a latent detection bug: drizzle's Durable Objects session class is SQLiteDOSession, not the previously-checked SQLiteDurableObjectSession, so a real drizzle(ctx.storage) store was silently misclassified.

TransactionContext.sql — graph-owned cross-store writes (all transactional backends). store.transaction(async (tx) => …) now exposes tx.sql, the raw Drizzle handle bound to the same transaction, so the caller's own relational tables join the same atomic boundary — the graph-owned counterpart of store.withTransaction. On Postgres/libsql this is a correctness requirement (the outer db is a different connection and would escape the transaction); undefined only on non-transactional backends.

await store.transaction(async (tx) => {
  await tx.nodes.Document.update(documentId, props);
  await tx.sql.insert(documentVersions).values(versionRow);
});

Invariants preserved

Schema bootstrap/DDL and the durable fulltext materialization marker run outside any storage transaction; only the data-only schema-version commit uses the storage runner — "no DDL in the business transaction" holds.

Testing

  • New pnpm test:do lane (@cloudflare/vitest-pool-workers) driving a real Durable Object in workerd: cross-store commit/rollback, product-key-conflict rollback, same-document contention.
  • Graph-owned tx.sql commit/rollback coverage on better-sqlite3, libsql, Postgres, and do-sqlite.
  • Full SQLite suite (3105) + do-sqlite (8) + Postgres cross-store (8) green; typecheck/lint/format clean.

Out of scope

Cloudflare D1 stays transactionMode: "none": D1Database.batch(...) is transactional but batch-only, not an interactive runner. A batch-mode D1 path is tracked separately (per the issue's own scoping).

…sql (#140)

Cloudflare Durable Objects SQLite stores are now fully transactional, and
graph-owned transactions expose the bound SQL handle so one
store.transaction() can atomically write both TypeGraph data and the
caller's own relational tables.

Durable Objects (drizzle(ctx.storage)):

- Auto-detected as a new SQLite transactionMode "do-sqlite";
  capabilities.transactions is now true (previously false).
- store.transaction() and store.withTransaction() are atomic, delegating
  to the async ctx.storage.transaction runner (db.$client.transaction),
  which rolls back across await. Drizzle's own db.transaction() on DO is
  ctx.storage.transactionSync and cannot span an await, so it is not used.
- Fixes a latent detection bug: drizzle's DO session class is
  SQLiteDOSession, not the previously checked SQLiteDurableObjectSession,
  so a real drizzle(ctx.storage) store was misclassified.
- Schema bootstrap/DDL and the durable fulltext marker run outside any
  storage transaction; only the data-only schema-version commit uses the
  storage runner, preserving "no DDL in the business transaction".
- Cloudflare D1 stays non-transactional: D1Database.batch is batch-only,
  not an interactive runner. Tracked separately.

TransactionContext.sql (all transactional backends):

- store.transaction(async (tx) => ...) now exposes tx.sql, the raw
  Drizzle handle bound to the same transaction, so the caller's
  relational tables join the same atomic boundary -- the graph-owned
  counterpart of store.withTransaction. On Postgres/libsql this is a
  correctness requirement (the outer db is a different connection).
  undefined only on non-transactional backends. Its static type is the
  AdoptedTransaction union; cast to the concrete Drizzle db at the call
  site (examples and JSDoc show the cast).

Testing & docs:

- New workerd test lane (pnpm test:do, @cloudflare/vitest-pool-workers)
  driving a real Durable Object, with a dedicated CI job running it on
  every PR and main: cross-store commit/rollback, product-key conflict
  rollback, same-document contention.
- Graph-owned tx.sql commit/rollback coverage on better-sqlite3, libsql,
  Postgres, and do-sqlite.
- Docs updated: backend setup, limitations, integration, cross-store
  recipe, store.transaction JSDoc.

Closes #140
@pdlug pdlug force-pushed the feat/do-sqlite-transactions branch from ad587fe to 531524d Compare May 16, 2026 22:44
@pdlug pdlug merged commit 02c98a9 into main May 16, 2026
11 checks passed
@pdlug pdlug deleted the feat/do-sqlite-transactions branch May 16, 2026 22:49
@github-actions github-actions Bot mentioned this pull request May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support transactional writes and tx.sql enlistment for Cloudflare Durable Objects SQLite

1 participant