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.

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.