Transactions API
Atomic multi-statement writes, how the client is threaded through them, and what the library does not offer
Stabilize.transaction()
async transaction<T>( callback: (txClient: DBClient) => Promise<T>,): Promise<T>Runs the callback inside a transaction and resolves to whatever the callback returns. A throw anywhere inside rolls the whole thing back and the error propagates to the caller unchanged.
Parameters:
- callback Async function receiving the transactional
DBClientas its only argument.
const userRepo = orm.getRepository(User);const profileRepo = orm.getRepository(Profile);
await orm.transaction(async (tx) => { const user = await userRepo.create({ name: "Ciniso" }, {}, tx); await profileRepo.create({ userId: user.id, bio: "A new bio" }, {}, tx);});// Both rows commit together, or neither is written.DBClient.transaction()
async transaction<T>( callback: (txClient: DBClient) => Promise<T>,): Promise<T>The method Stabilize.transaction() delegates to. It is public on DBClient and exported from the package root, so it can be called directly on orm.client. It throws a StabilizeError with code TX_ERROR when the underlying client is none of the five supported drivers. MongoDB is one of the five; its driver is an optional dependency, and it serves transactions only on a replica set or sharded cluster.
You must pass the client
Without tx, the write runs outside the transaction.
Every repository method resolves its client as _client || this.client. The repository holds its own client from construction, so a call that omits the transactional client does not fail — it opens a separate transaction on a different pool connection, commits immediately, and survives the rollback. The transaction object is threaded by argument only; there is no ambient or async-local context to pick it up for you.
// WRONG: the second write is not in the transaction.await orm.transaction(async (tx) => { await userRepo.create({ name: "Alice" }, {}, tx); await auditRepo.create({ action: "user.created" }); // no tx!});
// RIGHT: pass tx to every call that should be covered.await orm.transaction(async (tx) => { await userRepo.create({ name: "Alice" }, {}, tx); await auditRepo.create({ action: "user.created" }, {}, tx);});The transactional client is the third argument on the write paths that accept it: create(entity, options, client), update(id, entity, client), upsert(entity, keys, client), delete(id, client), recover(id, client), and the bulk equivalents. On reads it is likewise the trailing argument of findOne, findBy and findOneBy.
Nested transactions are flattened
if (this.isTransactionClient) return callback(this);A client already bound to a transaction satisfies a nested transaction() by handing back itself and running the callback directly. No second BEGIN is issued and no marker is created.
This is what makes the library's own write methods safe to compose: create(), update() and the other write paths open a transaction internally when they are not already inside one, so calling them with a tx client participates in your transaction rather than nesting inside it.
There are no savepoints.
Nothing in the API maps to SAVEPOINT or ROLLBACK TO SAVEPOINT. A throw in an inner callback rolls back the entire transaction, not just the inner block, because the inner block is not a transaction of its own. To get partial rollback, catch the error inside the callback and decide what to do with the outer transaction.
What each dialect runs
The callback contract is identical everywhere, but the mechanism underneath is driver-specific:
- SQLite Literal
BEGIN/COMMIT/ROLLBACK. SQLite runs on a single connection, so the callback receives the same client rather than a new one. - PostgreSQL Borrows a connection from the pool and releases it in a
finally, so the connection is returned whether the callback succeeds or throws. - MySQL Same borrow-and-release, opening with
START TRANSACTIONrather thanBEGIN. - SQL Server Has no
BEGIN/COMMITtext at all: the transaction is a server-side object and every statement is sent through aRequestbuilt from it. There is no release step — commit or rollback returns the borrowed connection to the pool.
// The SQL differs; the call site does not.await orm.transaction(async (tx) => { await repo.create({ name: "Alice" }, {}, tx);});Not configurable
transaction() takes exactly one argument. There is no isolation-level option, no read-only flag, no timeout and no retry: the driver's default isolation applies. On SQL Server the underlying begin() does accept an isolation level, but the library never passes one.
// No such options exist:await orm.transaction(async (tx) => { /* ... */ }, { isolationLevel: "serializable", // not part of the API});A deadlock or serialization failure surfaces as the driver's own error. If you need a retry, wrap the whole transaction() call in your own loop — the library will not re-run the callback for you.
Methods that transact on their own
These write paths already wrap themselves in a transaction, so a single call is atomic without any explicit transaction() around it. Inside an outer transaction they join it instead of starting a second one:
create() bulkCreate() update() bulkUpdate()upsert() bulkUpsert() delete() bulkDelete()recover() rollback() sync() upsertMany()A method not on this list — a bare updateBy() or deleteBy(), say — runs as a single statement with no wrapping transaction, so wrap it yourself when it has to be atomic with something else.
TX_ERROR
class StabilizeError extends Error { code: string; originalError?: unknown;}Raised with the message Transaction not supported by this client configuration. when the client behind the call is not a recognised SQLite, PostgreSQL, MySQL, SQL Server or MongoDB handle.
import { StabilizeError } from "stabilize-orm";
try { await orm.transaction(async (tx) => { /* ... */ });} catch (err) { if (err instanceof StabilizeError && err.code === "TX_ERROR") { // The client is not one of the five supported drivers. }}