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_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.
  • 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.store after throwing. The transaction is rolled back — your write is gone. Plan for compensation in the caller.