Transactions API

Atomic multi-statement writes, how the client is threaded through them, and what the library does not offer

Stabilize.transaction()

typescript
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 DBClient as its only argument.
example/transaction.ts
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()

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

example/missing-tx.ts
// 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

typescript
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 TRANSACTION rather than BEGIN.
  • SQL Server Has no BEGIN/COMMIT text at all: the transaction is a server-side object and every statement is sent through a Request built from it. There is no release step — commit or rollback returns the borrowed connection to the pool.
example/dialects.ts
// 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.

typescript
// 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:

typescript
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

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

example/tx-error.ts
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.
}
}