Transactions
How ctx.store behaves inside mutations, workflows, and explicit transaction blocks.
Every mutation executor runs inside an implicit Postgres transaction. Workflows manage transactions per step. You rarely call BEGIN/COMMIT directly — but when you do, the API is clean.
The default: one mutation = one transaction
// apps/api/mutations/orders.create.mutation.ts
export default async (input, ctx) => {
const order = await ctx.store.insert('orders', { /* … */ })
for (const line of input.lines) {
await ctx.store.insert('orderLines', { orderId: order.id, ...line })
}
return { id: order.id }
}The framework wraps this in BEGIN; … COMMIT;. If the executor throws (a typed error or any unhandled exception), the transaction rolls back — no half-inserted orders.
CDC events fire only AFTER commit. Subscribers don't see uncommitted writes.
Explicit transactions
When you need a transaction across multiple top-level operations (rare in mutations, which are already transactional; common in setup scripts + workflow steps), use ctx.store.transactional(work):
await ctx.store.transactional(async (tx) => {
await tx.insert('users', { /* … */ })
await tx.insert('teams', { /* … */ })
await tx.insert('memberships', { /* … */ })
// Throwing here rolls back all three.
})tx is a ctx.store clone scoped to one transaction. Reads via tx.query(...) / tx.select(...) inside see the in-flight write set (read-your-own-writes within the transaction). ChangeEvents queue inside the transaction and emit ONLY after a successful commit, so subscribers never see a partial-update flicker.
transactional() must not be nested — a nested invocation throws to surface a design mistake. Use a single top-level transactional() per unit of work.
Typed errors survive a transaction
A Data.TaggedError thrown inside a transaction reaches the caller — and the browser client — as itself: _tag, payload and prototype intact, so a mutation's declared error: union still matches.
import { Data } from 'effect'
class NoteNotFound extends Data.TaggedError('NoteNotFound')<{ noteId: string }> {}
// apps/api/mutations/notes.rename.mutation.ts — auto-transactional
export default async (input, ctx) => {
const note = await ctx.store.query({ table: 'notes', /* … */ })
if (!note) throw new NoteNotFound({ noteId: input.id }) // arrives typed on the client
// …
}On the client:
const res = await client.notes.rename({ id, title })
if (res.error?._tag === 'NoteNotFound') { /* this branch fires */ }This holds on every dialect and on both tenancy topologies — shared-schema and namespace isolation — because all four dialect stores and both postgres entry points run through one shared transaction bracket. Never match on error.message to identify a transaction failure; the _tag is the contract.
Automatic retry on transient conflicts
A transaction that fails with a transient contention error is retried automatically: exponential backoff from 10 ms, up to 3 retries (4 attempts total). What counts as transient is per-dialect — postgres serialization_failure (40001) and deadlock_detected (40P01), mysql/mariadb deadlock and lock-wait timeout, mssql deadlock victim (1205), sqlite SQLITE_BUSY/SQLITE_LOCKED, and Turso's MVCC write-write conflict.
Two properties worth knowing:
- A conflict raised by the COMMIT itself is retried too. Under
SERIALIZABLE, the engine can only detect some conflicts at commit time — those are caught and replayed like any other. - Each attempt gets a fresh transaction and a fresh event buffer. A retried attempt's queued ChangeEvents are discarded with it, so subscribers see exactly one event set: the winning attempt's.
Because the body can run more than once, keep transactional() bodies idempotent — no counters incremented in JS, no external calls (see the anti-patterns below).
Optimistic concurrency
The fluent update builder carries an .expectVersion(n) guard: the update only matches rows whose version equals n, and throws OptimisticLockError when none match (the row was concurrently changed). Add a version column to the table to use it.
await ctx.store.update('notes').where('id', id).expectVersion(3).set({ title: 'x' })Workflows + transactions
Workflows are step-machines, not transactions — a workflow spans many transactions across many processes. Don't wrap a whole workflow in a transaction. Instead, scope each yield* step() to its own transaction:
import { Effect } from 'effect'
export default ({ orderId }) =>
Effect.gen(function* () {
yield* recordPaymentAttempt(orderId) // tx 1 — commits before the next step starts
yield* chargeStripe(orderId) // external — no transaction
yield* markOrderPaid(orderId) // tx 2
})If the worker dies between chargeStripe + markOrderPaid, the next worker picks up at markOrderPaid — the durable workflow log knows tx 1 committed already.
CDC + reactivity
Mutation writes trigger Postgres logical replication events. The runtime reads from a replication slot, decodes each WAL message, and queries invalidations to every subscription whose tracked read set touches the changed row.
This means:
- Writes from outside the framework (psql, dump-restore, external workers) ARE picked up. Subscribers see them like any other write.
- Writes inside
pg_dump/pg_restorebypass logical replication and DON'T invalidate. Alwaysvoltro migrateafter a restore. - Uncommitted writes don't invalidate. Subscribers see the post-commit state, never a torn read.
Connection pooling
Voltro runs on @effect/sql's per-dialect driver, which manages its own connection pool. Connection details flow from the standard env vars (PG_HOST / PG_PORT / PG_USER / PG_PASSWORD / PG_DATABASE for postgres, the MYSQL_* / MARIADB_* / MSSQL_* / DB_URL analogues for the others). For high-concurrency setups (1k+ rps), front Postgres with PgBouncer in transaction mode.
Anti-patterns
- Long transactions. Keep
transactional()bodies short — the longer the BEGIN…COMMIT window, the more contention. Do external I/O (HTTP, AI calls) OUTSIDE the transaction, in an action or a workflow step. - Nesting
transactional(). It throws. Build one top-level transaction per unit of work. - Cross-transaction state in workflows. If a workflow needs "transactional" semantics across steps, it doesn't — it needs a saga (compensating actions on failure).
- Reading from
ctx.storeafter throwing. The transaction is rolled back — your write is gone. Plan for compensation in the caller.