feat: atomic transactions for Cloudflare Durable Objects SQLite + tx.sql (#140)#142
Merged
Conversation
…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
ad587fe to
531524d
Compare
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 SQLitetransactionMode: "do-sqlite"and reportscapabilities.transactions: true(previouslyfalse).store.transaction()andstore.withTransaction()roll back and commit atomically by delegating to the asyncctx.storage.transaction(async …)runner (Drizzle'sdb.$client.transaction), which rolls back acrossawait. Drizzle's owndb.transaction()on DO isctx.storage.transactionSyncand cannot span anawait, so it is deliberately not used.This also fixes a latent detection bug: drizzle's Durable Objects session class is
SQLiteDOSession, not the previously-checkedSQLiteDurableObjectSession, so a realdrizzle(ctx.storage)store was silently misclassified.TransactionContext.sql— graph-owned cross-store writes (all transactional backends).store.transaction(async (tx) => …)now exposestx.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 ofstore.withTransaction. On Postgres/libsql this is a correctness requirement (the outerdbis a different connection and would escape the transaction);undefinedonly on non-transactional backends.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
pnpm test:dolane (@cloudflare/vitest-pool-workers) driving a real Durable Object inworkerd: cross-store commit/rollback, product-key-conflict rollback, same-document contention.tx.sqlcommit/rollback coverage on better-sqlite3, libsql, Postgres, and do-sqlite.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).