Skip to content
VibORM
Esc
navigateopen⌘Jpreview
On this page

Transactions & Batching

Execute multiple operations with callback transactions or atomic batch mode

VibORM gives you two supported ways to run several operations together, and both are atomic — if any operation fails, none are applied:

  1. Interactive transactions — a callback where later operations can use results from earlier ones.
  2. Batch mode — an array of independent operations that share one atomic boundary.

Dynamic Transactions

Use the callback API when operations need to depend on each other:

await client.$transaction(async (tx) => {
  // Create a user
  const user = await tx.user.create({
    data: { name: "Alice", email: "alice@example.com" },
  });

  // Use the created user's ID in the next operation
  await tx.post.create({
    data: {
      title: "Hello World",
      authorId: user.id,  // Depends on previous operation
    },
  });
});

Transaction options

$transaction accepts a second argument with the same shape Prisma uses:

await client.$transaction(
  async (tx) => {
    /* ... */
  },
  {
    isolationLevel: "Serializable", // "ReadUncommitted" | "ReadCommitted" | "RepeatableRead" | "Serializable"
    timeout: 10_000,                // ms the callback may run before rollback
    maxWait: 2000,                  // ms to wait for a transaction slot
  }
);

The array form takes isolationLevel only — a preplanned array has no interactive window for timeout or maxWait to bound:

await client.$transaction([client.user.findMany(), client.user.count()], {
  isolationLevel: "Serializable",
});

Honored or refused, never ignored

Each option is honored where the driver can honor it, and rejected with a typed UnsupportedOperationError (V8003) where it cannot. An option you pass never silently does nothing. A malformed options object — an unknown key, a misspelled level, a non-positive duration — is a TransactionError (V5005), raised before any provider work begins.

Driver isolationLevel timeout maxWait
pg all four levels, SET TRANSACTION ISOLATION LEVEL after BEGIN honored honored — bounds the pool acquisition
postgres.js all four levels, first statement in the transaction honored refusedsql.begin() owns acquisition; the wait cannot be bounded
PGlite all four levels, first statement in the transaction honored honored — bounds the connection queue
Bun SQL all four levels, first statement in the transaction honored refusedsql.begin() owns acquisition
mysql2 all four levels, SET TRANSACTION ISOLATION LEVEL before BEGIN honored honored — bounds the pool acquisition
PlanetScale all four levels, before BEGIN on the transaction’s connection honored refused — each transaction opens its own HTTP connection with no wait
SQLite3 Serializable only honored honored — bounds the connection queue
Bun SQLite Serializable only honored honored — bounds the connection queue
libSQL Serializable only honored honored — queue (in-memory) or transaction() acquisition
D1 binding refused — batch-only, no transaction to configure refused refused
Neon HTTP refused — batch-only, no transaction to configure refused refused

Isolation levels

PostgreSQL-family drivers accept all four levels and apply them with SET TRANSACTION ISOLATION LEVEL as the transaction’s first statement. PostgreSQL itself treats ReadUncommitted as ReadCommitted — that is the server’s own documented behavior, and VibORM does not paper over it.

MySQL-family drivers issue the same statement before BEGIN, where it binds to exactly the next transaction on that connection. It deliberately does not change the session default, so a pooled connection never carries one caller’s isolation level over to the next.

SQLite-family drivers (SQLite3, Bun SQLite, libSQL) have no isolation-level statement: a transaction on their single connection is already serializable. Serializable is therefore honored by construction — nothing is emitted, because nothing needs to be. The three weaker levels are refused rather than silently upgraded, since accepting them would misreport what the transaction actually guarantees.

timeout

timeout bounds how long the callback body may run. On expiry the transaction is rolled back and the call rejects with a TransactionError (V5002). In-flight statements are drained before the rollback, so the connection is returned in a clean, reusable state and no partial write survives.

maxWait

maxWait bounds the wait for a transaction slot — before the body starts. A transaction that exceeds it never reaches BEGIN, so there is nothing to roll back; the call rejects with a TransactionError (V5002). Drivers that cannot observe or safely abandon their acquisition refuse the option instead of accepting a bound they could not enforce.

Nested transactions

A nested $transaction runs as a SAVEPOINT inside the outer transaction, so its option contract differs: timeout is honored (expiry rolls back to the savepoint), while isolationLevel and maxWait are refused. The outer transaction’s isolation level is already fixed and cannot be changed mid-transaction, and a savepoint reuses the connection the outer transaction already holds. Set isolationLevel on the outermost $transaction.

Raw SQL inside a transaction

Use the tx client’s own raw methods — tx.$queryRaw, tx.$executeRaw and their Unsafe variants run on the transaction’s connection and roll back with it. The originating client.$queryRaw does not guarantee a pinned session on pooled or HTTP drivers. See Raw SQL.

Use only the supplied transaction client

Portable callback-transaction code performs database work through the tx client supplied to the callback. Starting work through the originating client while that callback is active is outside the portable contract because drivers have different connection topologies: single-connection drivers reject that work fail-closed, and pooled drivers are not globally serialized. Do not depend on originating-client concurrency inside a callback transaction.

At every nesting level, use the client supplied to that callback. Parent-scope work admitted before nested activation is serialized after it; new parent-scope calls during the nested callback reject fail-closed.

Batch Mode (Prisma-style)

Use the array API for independent operations that should run together atomically. Sibling operations can’t read each other’s results, but a nested write inside a single operation works normally.

The array takes model operations only. A raw query runs the moment you call it and returns a plain Promise, so there is nothing left to batch — passing one is refused with a typed UnsupportedOperationError. Run raw SQL inside the interactive form instead.

const [users, posts, count] = await client.$transaction([
  client.user.findMany({ where: { active: true } }),
  client.post.findMany({ where: { published: true } }),
  client.user.count(),
]);

Batch Writes

const [user1, user2] = await client.$transaction([
  client.user.create({ data: { name: "Alice", email: "alice@example.com" } }),
  client.user.create({ data: { name: "Bob", email: "bob@example.com" } }),
]);

console.log(user1.name); // "Alice"
console.log(user2.name); // "Bob"

Mixed Operations

const [newUser, allPosts, userCount] = await client.$transaction([
  client.user.create({ data: { name: "Charlie", email: "charlie@example.com" } }),
  client.post.findMany(),
  client.user.count(),
]);

Operations are lazy

Operations don’t run until you await them (or pass them to $transaction([...])). This lets you build an operation and run it later:

// Build an operation without running it
const findUsersOp = client.user.findMany();

// Run it later
const users = await findUsersOp;

// Or run it as part of a batch
const [users, posts] = await client.$transaction([
  findUsersOp,
  client.post.findMany(),
]);

Driver support

Every connection-oriented driver supports both forms. D1 Workers bindings and Neon HTTP are batch-only, so their callback form throws. Cloudflare D1 support uses the Workers binding: its batch() primitive is the documented atomic path required by VibORM. An empty array always returns [] without contacting a provider.

The difference

Interactive transactions give you read-your-writes: later operations can use the results of earlier ones.

Batch mode runs independent operations under one atomic boundary, but siblings can’t read each other’s results. Drivers with native batch APIs may submit one provider call; connection-oriented drivers may execute sequential statements inside a transaction.

Both are all-or-nothing.

Choosing the Right Approach

Use Dynamic Transactions When:

  • Operations depend on each other’s results
  • You need isolation from other connections
  • You’re using a driver that supports transactions
// Good: user.id is needed for posts
await client.$transaction(async (tx) => {
  const user = await tx.user.create({ data: { name: "Alice" } });
  await tx.post.createMany({
    data: [
      { title: "Post 1", authorId: user.id },
      { title: "Post 2", authorId: user.id },
    ],
  });
});

Use Batch Mode When:

  • Operations are independent (any dependency inside a single nested write is fine)
  • You want independent operations to share one atomic boundary
  • You’re on a serverless driver that only supports batch mode (see Driver support)
// Good: sibling operations can be planned up front
const [users, posts, stats] = await client.$transaction([
  client.user.findMany(),
  client.post.findMany({ where: { published: true } }),
  client.user.aggregate({ _count: true, _avg: { age: true } }),
]);

Error handling

If any operation in a supported transaction or atomic batch fails, the whole thing is rolled back — you never end up with a partial write.

If rollback or savepoint cleanup also fails, the original operation failure remains the primary error and cleanup failures are attached in an AggregateError. A driver whose transaction state can no longer be proven safe is closed or poisoned instead of being reused.

Was this page helpful?