Transactions

How ctx.store behaves inside mutations, workflows, and explicit transaction blocks.

Every mutation executor runs inside a transaction of the configured store, including memory. Workflows manage transactions per step. Use the store's transaction API rather than issuing BEGIN/COMMIT yourself.

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.

SQL Server upserts await registered write recorders on the same transaction connection, including native MERGE and functional updates. A recorder failure rejects the write and rolls back its data and recorder appends; no change event is published. A direct store upsert on a recorded table opens this transaction automatically. The recorder receives the write's captured request attribution.

Mutation dispatch selects the request's regional/namespace store before opening the transaction. Namespace provisioning is awaited first; retries and post-commit folds keep that selected store. ctx.storeForTenant for the same tenant stays inside the transaction. With physical isolation or residency, changing the tenant through this method inside a mutation is refused: use separate tenant-scoped mutations, not a transaction spanning databases.

Namespace provisioning uses the same physical connection as the selected store, including a regional database. Its completion is memoized separately for each physical store and namespace; provisioning a namespace in the primary database does not certify its existence in a regional database. An unregistered physical store is refused, never provisioned through the primary connection as a fallback.

Regional background delivery follows the same placement. A workflow start queued inside such a mutation is written into the tenant's namespace of its regional database, and the admission host of every replica discovers it through the durable namespace directory — declared roots plus registered namespaces, never a tenant list the writer's process remembered. Measured on PostgreSQL: two regional databases whose tenants share a namespace name, one deployment-wide pool, a host built from fresh connections; one start admitted, the other held in its own region's submission, every receipt and caller binding in its own namespace of its own database. A queued receipt is still not proof that its start has run — read it with ctx.workflows.readStart — but it is proof that a regional worker will find it.

Explicit transactions

For database work in an action or schedule, use ctx.transaction(work). The callback receives a complete transaction-bound context, not a raw store:

await ctx.transaction(async (tx) => {
  await tx.store.insert('users', { /* … */ })
  await tx.store.insert('teams', { /* … */ })
  await tx.store.insert('memberships', { /* … */ })
  // Throwing here rolls back all three.
})

Use tx.store for all reads and writes inside the callback; it sees its own uncommitted writes. Do not capture the outer ctx.store. The callback retains caller identity and the selected physical store. Change events are published after commit. tx.workflows.start(...) persists a start intent in that same transaction and returns a provisional queued receipt, not proof of engine execution. Rollback removes the intent along with the domain writes.

Inside a mutation, ctx.transaction joins the existing transaction. Nested tx.transaction calls receive the same context: no savepoint and no independent commit. Let a failure escape the outer callback to roll back the whole unit. The callback may be retried, so keep external I/O outside it. ctx.store.transactional(work) also exists, but binds only the callback's store: it does not rebind the surrounding ctx.workflows. Use ctx.transaction when database writes and workflow start intents must share a commit.

A system context may explicitly choose a tenant before the transaction starts:

await ctx.transaction(async (tx) => {
  await tx.store.insert('reports', { title: 'Daily report' })
}, { tenantId })

The complete context is rebound, including workflow caller identity, residency and namespace selection. System work keeps a null application audit actor. An ordinary caller cannot choose another tenant; even a system caller cannot change tenants inside an active transaction. Use a separate transaction per tenant. This does not discover tenant databases or schedule work across them.

Under residency or namespace isolation, tenantless system contexts start unplaced. Database-backed capabilities refuse until this explicit selection; the context never falls back to a shared/default database.

Durable event delivery after commit retains the physical store and namespace selected before the transaction. It neither uses the closed transaction nor resolves placement again. Custom context lifecycle adapters that deliver these events must supply MutationLifecycle.committedStore, the selected nontransactional store; omission refuses context construction. The framework's mutation runner supplies it automatically. After-commit delivery alone is not a durable retry queue across process death.

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.

A caught failure ends the transaction — skip a row with savepoint

On Postgres a statement the server rejects aborts the whole transaction: every later statement is refused, and COMMIT is executed as a ROLLBACK — even the rows that were written before the failure are gone. Catching the error and carrying on therefore cannot commit anything, and transactional() says so: it fails with TransactionAbortedError, which names the caught failure, instead of resolving over a transaction that wrote nothing.

MySQL, MariaDB and SQL Server roll back only the failed statement — except on a deadlock, which rolls the whole transaction back and takes the session out of it. After such a failure no further statement is sent (it would otherwise run in autocommit, durable at once), and because a deadlock is transient the whole work is retried in a fresh transaction.

To skip a failing row and keep the rest, run the statement inside savepoint: a failure undoes only what ran inside it, on every dialect and on the in-memory store.

// apps/api/mutations/imports.run.mutation.ts — auto-transactional
export default async (input, ctx) => {
  const skipped: string[] = []
  for (const row of input.rows) {
    // Only this insert is rolled back when it fails; the loop and the rest commit.
    await ctx.store.savepoint!(() => ctx.store.insert('imports', row)).catch(() => skipped.push(row.id))
  }
  return { imported: input.rows.length - skipped.length, skipped }
}

A savepoint that fails to roll back leaves the transaction unusable — that too ends in TransactionAbortedError. And the retry note below applies here as well: the body may run again, so the skip list is rebuilt per attempt, not accumulated outside it.

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).

Memory transactions

Memory uses optimistic serializable transactions: each attempt reads a stable snapshot plus its own writes. Before commit, the store validates every table read or written, including empty-table reads and read-only dependencies. A concurrent change discards the attempt's writes and events and reruns the whole callback against a fresh snapshot, using the same four-attempt limit and backoff. Exhausted contention rejects with an error named MemoryTransactionConflict; application rejections are not retried and retain their identity. A settled transaction handle cannot be reused.

Validation is conservatively table-granular within the physical tenant namespace, not a blocking row lock. .forUpdate() does not serialize external calls in memory; never use it to protect HTTP, payment, or other effects inside a transaction. Independent namespaces and unrelated tables do not conflict. Snapshots copy the store's row maps per attempt, so this is a correctness guarantee for the development store, not a substitute for a production SQL engine or a database shared by multiple processes. Use the tx handle for all reads and writes in the callback.

The wrapped store's deferred CRDT folds also belong to one attempt. Only the committed attempt's folds run afterward; a discarded attempt cannot contribute document edits. This ownership rule applies to SQL retries as well.

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_restore bypass logical replication and DON'T invalidate. Always voltro migrate after 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.
  • Expecting a nested ctx.transaction to commit independently. It joins the outer transaction, without a savepoint.
  • 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.store after throwing. The transaction is rolled back — your write is gone. Plan for compensation in the caller.