Skip to content
NewFrom Mongoose to SQL6 min read

Transactions

A transaction runs a group of statements all-or-nothing. Pass a callback to pool.transaction(): UQL takes a connection, commits when the callback returns, rolls back when it throws, and releases the connection either way.

import { Profile, User } from './shared/models/index.js';
import { pool } from './uql.config.js';
const userId = await pool.transaction(async (querier) => {
const id = await querier.insertOne(User, { name: 'Alice' });
await querier.insertOne(Profile, { userId: id, bio: '...' });
return id;
});

A pool call made inside the callback joins the transaction. pool.insertOne(...), or a helper written against the pool or a UniversalQuerier, runs on the callback’s querier, sees its uncommitted writes and rolls back with them. Helpers need no changes to take part: the caller decides what is atomic.

To undo writes, throw. To give up before writing anything, return early: nothing is committed and any row locks are released.

querier.transaction() makes part of a querier’s work atomic. It commits or rolls back, and releasing the connection stays with whoever acquired it:

const userId = await pool.withQuerier(async (querier) => {
if (await querier.count(User, { $where: { email } })) {
return undefined;
}
return querier.transaction(async () => {
const id = await querier.insertOne(User, { name: 'Alice' });
await querier.insertOne(Profile, { userId: id, bio: '...' });
return id;
});
});

If you acquire the querier yourself, await using releases it however the block exits, so an early return or a throw cannot leak the connection. Node 24+ and Bun run it natively, and TypeScript lowers it for an older target. A try / finally calling querier.release() does the same.

await using querier = await pool.getQuerier();
await querier.transaction(async () => {
/* ... */
});

Releasing a querier with a transaction still open rolls it back and logs a warning. A connection whose rollback fails is discarded rather than reused. Releasing inside a transaction() callback makes that call reject, since nothing is left to commit. A released querier cannot be used again.

A transaction() inside another runs as a savepoint. If it throws, only its own writes roll back, and the outermost call still owns the commit. A helper that is atomic on its own is therefore safe to call from a larger transaction:

await pool.transaction(async (querier) => {
await querier.insertOne(Order, order);
await querier
.transaction(() => querier.insertOne(Coupon, coupon))
.catch(() => undefined); // the coupon rolled back; the order still commits
});

An inner failure you do not catch reaches the outer callback and rolls back everything.

Some side effects cannot be rolled back, such as sending an email or queueing a job. querier.onCommit(fn) runs fn once the outermost transaction commits:

await pool.transaction(async (querier) => {
await querier.insertOne(User, user);
await querier.onCommit(() => mailer.welcome(user.email));
});

If the transaction rolls back, fn never runs, and a savepoint that rolls back drops the callbacks registered inside it. Outside a transaction, fn runs at once. If fn throws, the transaction call rejects, but the writes are already committed. Lifecycle hooks get onCommit in their context.

When a transaction cannot be one callback, drive it yourself. A test that rolls back everything its body wrote is the usual case:

import type { Querier } from 'uql-orm';
let querier: Querier;
beforeEach(async () => {
querier = await pool.getQuerier();
await querier.beginTransaction();
});
afterEach(async () => {
await querier.rollbackTransaction();
await querier.release();
});

beginTransaction() throws pending transaction if one is already open on that querier. rollbackTransaction() does nothing when none is open, so it is safe in a catch or finally. commitTransaction() throws when none is open, so you never mistake uncommitted work for committed. Pool calls do not join a transaction opened this way, so run its statements on the querier.

A transaction holds its connection until it ends. A statement from outside the callback waits for it instead of landing in it, whether it comes from another request sharing the querier or, on SQLite, PGlite, libSQL and embedded Turso, from another querier on the one shared connection. Transactions on one querier run one after another.

Every form takes an isolationLevel: read uncommitted, read committed (the default on most databases), repeatable read or serializable.

await pool.transaction(
async (querier) => {
/* ... */
},
{ isolationLevel: 'serializable' },
);

PostgreSQL, MySQL, MariaDB and SQL Server take all four, and so does Bun SQL on PostgreSQL, MySQL and MariaDB. CockroachDB takes the option too, and runs a level it lacks at a stricter one. SQLite, libSQL, Turso and MongoDB ignore it; SQLite is already serializable. Cloudflare D1 has no transactions at all, so transaction() and beginTransaction() throw there: write the change as one statement, or make it idempotent.

At stricter levels, conflicting transactions fail with a serialization error or a deadlock instead of waiting. queryErrorKind names both retryable on every engine, so you can run the whole transaction again.

Set the level on every transaction that needs it. On MySQL and MariaDB it is sent as its own statement before START TRANSACTION, so if START TRANSACTION fails, the level carries over to the next transaction on that pooled connection.

For a read-modify-write at the default level, lock the rows you read with $lock, or one update can be lost. See Row Locking.

{ readOnly: true } opens a transaction that only reads, alone or beside an isolationLevel:

const users = await pool.transaction(
(querier) => querier.findMany(User, { $select: { id: true } }),
{
readOnly: true,
},
);

PostgreSQL, CockroachDB, MySQL, MariaDB and libSQL refuse a write inside it. libSQL and Turso open every other transaction with the write lock taken, so a read-only one is what lets a long read run beside the writers. SQLite, SQL Server and MongoDB have no such mode and run it as any other.