prorm.transaction() method that supports both an automatic callback mode and a manual mode, along with isolation levels, savepoints, automatic retry of serialization failures, an optional ambient transaction, and per-query transaction routing.Prorm owns the lifecycle (begin,TransactionError before any SQL isasync callback to prorm.transaction(). prorm opens a transaction, runs your callback, and commits automatically when the callback resolves. If the callback throws, the transaction is rolled back and the error is re-thrown to you.import { Prorm } from 'ts-prorm-orm';
const prorm = new Prorm({
dialect: 'postgres',
host: 'localhost',
database: 'mydb',
username: 'postgres',
password: 'password',
});
await prorm.transaction(async (t) => {
const from = await Account.findOne({ where: { id: 1 }, transaction: t });
const to = await Account.findOne({ where: { id: 2 }, transaction: t });
await from.decrement('balance', { by: 100, transaction: t });
await to.increment('balance', { by: 100, transaction: t });
// Reaching the end of the callback commits both updates.
});prorm.transaction(), so you can compute and return data from inside the transaction:const placed = await prorm.transaction(async (t) => {
const created = await Order.create({ userId: 1, total: 4200 }, { transaction: t });
await OrderItem.bulkCreate(items, { transaction: t });
return created; // committed, then returned to the caller
});try {
await prorm.transaction(async (t) => {
await Account.update({ balance: 0 }, { where: { id: 1 }, transaction: t });
throw new Error('Business rule violated');
});
} catch (err) {
// The UPDATE above was rolled back; the balance is unchanged.
console.error(err);
}TransactionError instead oftransaction option. This applies to model methods (create, bulkCreate, findAll, findOne, update, destroy, increment, decrement, …) as well as raw queries.await prorm.transaction(async (t) => {
// Model methods
const ada = await User.create({ name: 'Ada' }, { transaction: t });
const active = await User.findAll({ where: { active: true }, transaction: t });
await ada.increment('loginCount', { by: 1, transaction: t });
// Raw SQL
await prorm.query('UPDATE settings SET value = $1 WHERE key = $2', {
replacements: ['on', 'feature_x'],
transaction: t,
});
});transaction: t run outside the transaction and will not be rolled back with it, so be deliberate about threading t through every call — or turn on the ambient transaction described in An ambient transaction.transactionContext)new Prorm({ transactionContext: true }) propagates the transaction of aprorm.transaction(async (t) => …) callback implicitly, through Node'sAsyncLocalStorage: every query and model call made inside the callback runs{ transaction: t }, andprorm.getTransaction() returns it.import { Prorm } from 'ts-prorm-orm';
const prorm = new Prorm({ dialect: 'postgres', database: 'shop', transactionContext: true });
await prorm.transaction(async () => {
await Order.create({ userId: 1, total: 4200 }); // in the transaction
await OrderItem.bulkCreate(items); // in the transaction
await AuditLog.create({ action: 'order' }, { transaction: null }); // outside it
});transaction option run.{ transaction: null } opts one statement out; an explicit { transaction }awaits, so atransaction() calls find their parent through the same contextt:async function reserveStock(productId: number, quantity: number) {
// Runs in whatever transaction the caller opened, or none.
await Product.decrement('stock', { by: quantity, where: { id: productId } });
}
async function recordOrder(userId: number, productId: number, quantity: number) {
const order = await Order.create({ userId, total: 0 });
await OrderItem.create({ orderId: order.id, productId, quantity });
return order;
}
// The route handler owns the transaction boundary.
async function checkout(req: { userId: number; productId: number; quantity: number }) {
return prorm.transaction(async () => {
await reserveStock(req.productId, req.quantity);
return recordOrder(req.userId, req.productId, req.quantity);
});
}isolationLevel option. prorm accepts both the IsolationLevel enum and the equivalent ANSI SQL string.await prorm.transaction(
async (t) => {
await SalesReport.findAll({ transaction: t });
},
{ isolationLevel: 'SERIALIZABLE' },
);IsolationLevel.Serializable:import { IsolationLevel } from 'ts-prorm-orm';
await prorm.transaction(
async (t) => {
await SalesReport.findAll({ transaction: t });
},
{ isolationLevel: IsolationLevel.Serializable },
);IsolationLevel enum | String literal |
|---|---|
IsolationLevel.ReadUncommitted | 'READ UNCOMMITTED' |
IsolationLevel.ReadCommitted | 'READ COMMITTED' |
IsolationLevel.RepeatableRead | 'REPEATABLE READ' |
IsolationLevel.Serializable | 'SERIALIZABLE' |
READ COMMITTED on PostgreSQL, REPEATABLE READ on MySQL), unless isolationLevel was set on the Prorm options, which then applies to every root transaction. On PostgreSQL the level is emitted as part of the opening statement (BEGIN ISOLATION LEVEL ...).BEGIN. A request for a weakerREAD UNCOMMITTED asREAD COMMITTED). A request for a stronger level than the engine providesTransactionError — Snowflake tops out at READ COMMITTED, DuckDB,REPEATABLE READ, and Oracle accepts onlyREAD COMMITTED and SERIALIZABLE.Some dialects accept native, non-ANSI levels (for example SQL Server's SNAPSHOT, Db2'sUR/CS/RS/RRabbreviations, or Oracle'sREAD ONLY). Those are intentionally kept out of theIsolationLevelenum and can only be passed as plain strings to the dialects that support them.
prorm.transaction() accepts the full TransactionOptions object:await prorm.transaction(
async (t) => {
/* ... */
},
{
isolationLevel: 'SERIALIZABLE',
type: 'IMMEDIATE', // SQLite BEGIN type: 'DEFERRED' | 'IMMEDIATE' | 'EXCLUSIVE'
autocommit: false,
lock: 'UPDATE', // apply row locking to reads in the transaction
readOnly: true,
deferrable: 'INITIALLY DEFERRED',
retry: { max: 3 },
requireAtomic: true,
},
);isolationLevel – ANSI isolation level (enum or string), as above.type – transaction start type used by SQLite: 'DEFERRED' (default), 'IMMEDIATE', or 'EXCLUSIVE'. Anything but DEFERRED is refused on other engines.autocommit – toggles the driver's autocommit behavior for the transaction.lock – applies a lock clause to queries in the transaction. Accepts true / 'UPDATE' (FOR UPDATE), 'SHARE' (FOR SHARE on PostgreSQL, LOCK IN SHARE MODE on MySQL), 'KEY SHARE' (PostgreSQL FOR KEY SHARE), or { of: Model } to lock only a specific table (PostgreSQL).readOnly – BEGIN … READ ONLY (PostgreSQL) / START TRANSACTION READ ONLY (MySQL). Refused elsewhere.deferrable – true / false for PostgreSQL's DEFERRABLE transaction mode; 'INITIALLY DEFERRED' / 'INITIALLY IMMEDIATE' runs SET CONSTRAINTS ALL … so deferrable constraints are checked at commit or per statement. Refused on engines without the feature.retry – re-run the callback on a serialization failure or deadlock; see Retrying.transaction – the parent to nest in (a savepoint), or null to force an independent root transaction from inside another callback.requireAtomic – throw on the HTTP dialects (D1, rqlite, the Redshift Data API, Druid) whose "transaction" runs each statement as its own request and cannot be rolled back. By default those run the callback best-effort.BEGIN. The refusals all throwTransactionError, whose message names the option and the engine:mysql has no DEFERRABLE transaction mode; drop `deferrable`.
Transaction type 'IMMEDIATE' is SQLite's BEGIN mode; postgres has no equivalent.
d1 cannot make a transaction atomic: every statement runs as its own request and is applied immediately, and a rollback cannot undo it. Use the dialect's batch() for an all-or-nothing unit, or drop requireAtomic to run best-effort.isolationLevel, readOnly, deferrable, a non-DEFERRED type or retry;buildBeginSQL(), buildSetIsolationSQL() and buildStartTransactionSQL()):BEGIN:| Options | Statement |
|---|---|
| none | BEGIN |
{ isolationLevel: 'SERIALIZABLE' } | BEGIN ISOLATION LEVEL SERIALIZABLE |
{ isolationLevel: 'REPEATABLE READ', readOnly: true } | BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY |
{ isolationLevel: 'SERIALIZABLE', readOnly: true, deferrable: true } | BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE |
deferrable: true is only legal on a SERIALIZABLE READ ONLY transaction,BEGIN:PostgreSQL: DEFERRABLE applies only to a SERIALIZABLE READ ONLY transaction; pass { isolationLevel: 'SERIALIZABLE', readOnly: true, deferrable: true }.SET TRANSACTION applies only to the nextreadOnly: true spells the access mode out as START TRANSACTION READ ONLYreadOnly: false as START TRANSACTION READ WRITE). With neither, thebeginTransaction() sends a plain START TRANSACTION.type | Statement |
|---|---|
'DEFERRED' (default) | BEGIN TRANSACTION |
'IMMEDIATE' | BEGIN IMMEDIATE TRANSACTION |
'EXCLUSIVE' | BEGIN EXCLUSIVE TRANSACTION |
transaction() sends SAVEPOINT <name> instead. Use IMMEDIATESQLITE_BUSY half way through.readOnly has nowhere to go and is refused:sqlite cannot start a read-only transaction here; drop `readOnly` or use a read-only connection.BEGIN, SAVEPOINT, COMMIT) are sentlogging callback; only the statements you run inside it do. Capturing aSELECT * FROM "accounts" WHERE "id" = ? LIMIT 1
UPDATE "accounts" SET "balance" = 400 WHERE "id" = 1
UPDATE "accounts" SET "balance" = "balance" + 100 WHERE "id" = 2
INSERT INTO "transfers" ("fromId", "toId", "amount") VALUES (?, ?, ?)lock to the read, inside the transaction, and aimport { Op } from 'ts-prorm-orm';
await prorm.transaction(async (t) => {
const products = await Product.findAll({
where: { id: { [Op.in]: [1, 2] } },
lock: true,
transaction: t,
});
// Nobody else can change these rows until this callback returns.
});lock also takes { level, nowait, skipLocked, of }. The same three callstype: 'IMMEDIATE' to take the write lock before the first read.[!WARNING] lockoutside a transaction locks nothing useful: the statement commits on
its own and the lock is released before your next line runs. Always pair it
withtransaction: t.
skipLocked is what makes a table usable as a work queue. Each worker claimsasync function claimJobs(workerId: string) {
return prorm.transaction(async (t) => {
const jobs = await Job.findAll({
where: { status: 'queued' },
order: [['id', 'ASC']],
limit: 10,
lock: { level: 'UPDATE', skipLocked: true },
transaction: t,
});
for (const job of jobs) {
await job.update({ status: 'running', workerId }, { transaction: t });
}
return jobs;
});
}skipLocked, worker 2 would block on job 1 until worker 1 commits,nowait it would fail at once instead.SERIALIZABLE a serialization failure is a normal outcome, and aretry: { max } re-runs the whole callbackawait prorm.transaction(
async (t) => {
const account = await Account.findOne({ where: { id: 1 }, transaction: t, lock: true });
await account.increment('balance', { by: 100, transaction: t });
},
{ isolationLevel: 'SERIALIZABLE', retry: { max: 3 } },
);BEGIN.max: 3 runs the callback at most four times. max must be a non-negative40001 (serialization failure)40P01 (deadlock detected); MySQL/MariaDB 1213 (ER_LOCK_DEADLOCK) and1205 (ER_LOCK_WAIT_TIMEOUT); SQL Server 1205; Oracle ORA-08177 andORA-00060. A dialect's own isRetryableError() (CockroachDB has one) isparent,original and cause chain.on: [...] replaces that list with your own codes or message fragments.backoffBase (default 25 ms) doubles per attempt, capped at backoffMaxprorm.transaction({ retry }) throws. Only the outermost transaction can[!WARNING]
Because the whole callback re-runs, keep side effects that are not part of
the transaction (sending an email, charging a card, enqueueing a job) out of
it, or make them idempotent. A retried callback that sends an email sends it
once per attempt, and a rolled-back one sends it for an order that does not
exist.
import { Op, TransactionError } from 'ts-prorm-orm';
type CartLine = { productId: number; quantity: number };
async function placeOrder(userId: number, cart: CartLine[]) {
const order = await prorm.transaction(
async (t) => {
// 1. Lock the rows whose values the writes depend on.
const products = await Product.findAll({
where: { id: { [Op.in]: cart.map((l) => l.productId) } },
lock: true,
transaction: t,
});
// 2. Check, then write. A throw here rolls everything back.
let total = 0;
for (const line of cart) {
const product = products.find((p) => p.id === line.productId);
if (!product || product.stock < line.quantity) {
throw new Error(`Out of stock: product ${line.productId}`);
}
await product.decrement('stock', { by: line.quantity, transaction: t });
total += product.price * line.quantity;
}
const created = await Order.create({ userId, total }, { transaction: t });
await OrderItem.bulkCreate(
cart.map((l) => ({ orderId: created.id, ...l })),
{ transaction: t },
);
// 3. Optional work in a savepoint: losing it must not lose the order.
try {
await prorm.transaction(
async (sp) => {
await LoyaltyPoints.create(
{ userId, points: Math.floor(total / 100) },
{ transaction: sp },
);
},
{ transaction: t },
);
} catch (err) {
if (err instanceof TransactionError) throw err; // misuse, not a business failure
// Points can be re-credited later; the order stands.
}
return created;
},
// 4. Two buyers racing for the last item is normal; retry the loser.
{ isolationLevel: 'SERIALIZABLE', retry: { max: 3 } },
);
// 5. Side effects after COMMIT, exactly once.
await mailer.send({ to: userId, template: 'order-confirmed', orderId: order.id });
return order;
}prorm.transaction() without a callback to get a transaction object you drive yourself. You are then responsible for calling commit() or rollback(). Options may be passed as the first argument in this mode.const t = await prorm.transaction({ isolationLevel: 'READ COMMITTED' });
try {
await Account.update({ balance: 500 }, { where: { id: 1 }, transaction: t });
await Account.update({ balance: 500 }, { where: { id: 2 }, transaction: t });
await t.commit();
} catch (err) {
await t.rollback();
throw err;
}createSavepoint(). The returned savepoint (a SavepointTransaction) has its own commit() (which issues RELEASE SAVEPOINT) and rollback() (which issues ROLLBACK TO SAVEPOINT, then releases the name so it does not pile up). It is not a new connection: a statement passed the savepoint runs on the outermost transaction's connection, inside the savepoint.prorm_sp_<n>) unless you pass one. A name ends up in SQL[A-Za-z_][A-Za-z0-9_]* (up to 128 characters) is refused rather than escaped:Invalid savepoint name "x; DROP TABLE t": a savepoint name must match /^[A-Za-z_][A-Za-z0-9_]*$/ (letters, digits and underscores, not starting with a digit).| Engine | Take | Release | Roll back to |
|---|---|---|---|
| PostgreSQL, SQLite | SAVEPOINT "sp1" | RELEASE SAVEPOINT "sp1" | ROLLBACK TO SAVEPOINT "sp1" |
| MySQL | SAVEPOINT `sp1` | RELEASE SAVEPOINT `sp1` | ROLLBACK TO SAVEPOINT `sp1` |
| SQL Server | SAVE TRANSACTION [sp1] | none (no RELEASE) | ROLLBACK TRANSACTION [sp1] |
| Oracle | SAVEPOINT "sp1" | none (no RELEASE) | ROLLBACK TO SAVEPOINT "sp1" |
| Db2 | SAVEPOINT "sp1" ON ROLLBACK RETAIN CURSORS | RELEASE SAVEPOINT "sp1" | ROLLBACK TO SAVEPOINT "sp1" |
RELEASE, sp.commit() sends nothing and the savepointNO_SAVEPOINT_ENGINES insrc/transaction-support.ts: Snowflake, DuckDB, MotherDuck, D1, rqlite,SAVEPOINT — refuse to nest instead of pretending, andcapabilitiesOf(dialect).savepoints is false for each of them:Nested transactions need savepoints, and snowflake has none. Run the work in a single transaction, or restructure it so nothing nests.[!NOTE]
Before 4.9.2 the capabilities disagreed with what nesting did. Redshift,
SingleStore, Synapse, Fabric, Doris and StarRocks claimed savepoints, but a
nestedtransaction()threw; Materialize, RisingWave, Trino, CrateDB,
QuestDB, Exasol, Teradata, Spanner PG, Athena and Presto claimed none, butcreateSavepoint()still sent an inheritedSAVEPOINT, which the server
rejected. The savepoint builders now refuse wherever the capabilities say
there are none. Code that branched oncapabilitiesOf().savepointsgets
the right answer; code that caught the server's error now gets aTransactionErrorbefore anything is sent.
await prorm.transaction(async (t) => {
await User.create({ name: 'Ada' }, { transaction: t });
const sp = await t.createSavepoint();
try {
await AuditLog.create({ action: 'risky' }, { transaction: t });
await sp.commit(); // RELEASE SAVEPOINT — keep the audit row
} catch (err) {
await sp.rollback(); // ROLLBACK TO SAVEPOINT — undo just the audit row
// The User.create above is still intact and will commit with t.
}
});rollback():await prorm.transaction(async (t) => {
const sp = await t.createSavepoint();
await Item.create({ sku: 'A-1' }, { transaction: t });
// Undo everything since `sp` was created, but keep the transaction open.
await t.rollback(sp);
await Item.create({ sku: 'B-2' }, { transaction: t }); // still works
});try/catch around prorm.transaction() is enough. Never call commit()/rollback() yourself inside the callback.try/catch and call rollback() on failure, otherwise the transaction stays open and holds its connection.[!CAUTION]
A manual transaction that is never committed or rolled back keeps its
connection checked out and its row locks held. Under load that is how a
pool runs dry: every request that touches the locked rows waits, then times
out. If a code path can return early, putrollback()in afinallythat
checks whethercommit()ran.
// Callback mode: catch around the whole call.
try {
await prorm.transaction(async (t) => {
await transferFunds(t);
});
} catch (err) {
logger.error('Transfer failed and was rolled back', err);
}SERIALIZABLE isolation) — retry: { max } does exactly that for you.error.rollbackError.TransactionError up front rather than running aThis transaction has already been committed or rolled back; …);Cannot nest in a transaction that has
already finished.).BEGIN … COMMIT ontransactionCapabilities(), either declared by thetransactionCapabilitiesFor() insrc/transaction-support.ts. This is what the option validation above reads:const dialect = prorm.getDialectInstance();
const caps = dialect?.transactionCapabilities?.();
if (caps && !caps.savepoints) {
// nest nothing on this engine
}| Field | Means |
|---|---|
savepoints | SAVEPOINT (or an equivalent) exists, so a transaction can nest. |
releaseSavepoint | A savepoint can be released explicitly (Oracle and SQL Server cannot). |
maxIsolation | The strongest ANSI level the engine guarantees; null means no transactions to isolate. |
isolationLevels | The ANSI levels accepted when not every level up to maxIsolation is (Oracle). |
nativeIsolationLevels | Engine-native levels accepted as-is (SNAPSHOT, UR/CS/RS/RR, READ ONLY). |
readOnly, deferrable, constraintDeferral | Which transaction modes startTransaction() applies. |
lockingType | SQLite's type: 'DEFERRED' | 'IMMEDIATE' | 'EXCLUSIVE'. |
atomic | Statements between BEGIN and commit are all-or-nothing; false for the HTTP engines. |
savepoints: true, releaseSavepoint: true,
maxIsolation: 'SERIALIZABLE', atomic: true; for D1 as savepoints: false,
atomic: false. The per-engine values are in the dialect pages.prorm.transaction(async (t) => { ... }) runs work atomically, committing on success and rolling back on error.transaction option, or set transactionContext: true and let the callback's context carry it.isolationLevel (ANSI string, or the enum); without it the engine's default applies. A level the engine cannot provide throws.createSavepoint() for partial rollbacks, and t.rollback(savepoint) to unwind to a point without ending the transaction. Engines without savepoints refuse to nest.retry: { max } re-runs the callback on serialization failures and deadlocks; callback mode only.