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