@zudojs/transactions
Run a group of database writes so that either all of them happen or none of them do, with nesting, savepoints, retries and hooks.
OVERVIEW
A transaction is a group of database changes that the database treats as one thing: either all of them happen, or none of them do. Take money off one account and add it to another — a transaction is what stops the first write from surviving on its own when the second one fails.
Two words describe how a transaction ends. Commit means "keep everything I did" — the changes become permanent and other people can see them. Rollback means "throw everything I did away" — the database goes back to how it looked before the transaction started.
The group of work you wrap in one transaction is called a unit of work. "Place an order" is a unit of work: insert the order row, insert the line items, decrease the stock count. All three, or none.
@zudojs/transactions does not talk to any database. You give it an adapter — a small object that knows how to say BEGIN, COMMIT and ROLLBACK to your particular database — and it handles everything above that: when to commit, when to roll back, what a transaction inside another transaction should do, retrying after a deadlock, and running your hooks. See @zudojs/database for the database side.
When you need it
- • One user action writes to several tables and a half-finished result would be wrong.
- • Service functions call each other and you want one transaction across all of them, not one each.
- • You want retries on deadlocks and serialization failures without writing the retry loop yourself.
- • You want to run code only after a commit really succeeded (send the email, publish the event).
When you don't
- • A single write. Your database already makes one statement all-or-nothing.
- • Reads only. Wrapping a read in a transaction buys you nothing unless you need a specific isolation level.
- • Work that spans two systems that cannot share a transaction, such as a database plus a payment provider. Use a saga or an outbox instead.
- • Your database driver's own
transaction()helper is enough and nothing nests.
INSTALLATION
Install the package. It pulls in @zudojs/errors on its own, and it needs Node 24 or newer.
These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.
QUICK START
The package ships an in-memory adapter so you can try everything without a database. This opens a transaction, runs your code inside it, and commits when your code returns.
run() returns whatever your callback returns, so orderId is the string "ord_1". If your callback throws instead, run() rolls the transaction back and rethrows your error — you never write a try/catch just to roll back.
Reach for run() first. begin(), commit() and rollback() exist for the rare case where a transaction must outlive one function call. Everything else is easier and safer with run().
UNIT OF WORK
The callback you pass to run() is the unit of work. Put everything that must succeed together inside it, and nothing that must not be undone.
This is the whole shape of it: three writes, one transaction. If decreaseStock throws, the order row and the line items are rolled back too, and the caller sees the original error.
You should see insert order, insert line items, then out of stock. The two inserts were rolled back before the error reached your catch.
Doing it by hand
When you need the transaction open across several calls, use begin() and finish it yourself. Every path must end in a commit or a rollback, or the database connection is left holding an open transaction.
The states a transaction moves through
transaction.state tells you where it is. The order is fixed, and an illegal jump throws a TransactionStateError rather than quietly doing nothing.
| State | What it means | Can move to |
|---|---|---|
pending | Created, but the adapter has not opened it yet. | active, rolling_back, failed |
active | Open. This is where your writes happen. | committing, rolling_back, failed |
committing | The commit is under way. | committed, rolling_back, failed |
committed | Done and kept. Nothing follows. | — |
rolling_back | The rollback is under way. | rolled_back, failed |
rolled_back | Done and thrown away. Nothing follows. | — |
failed | The adapter refused a commit or a rollback. Nothing follows. | — |
Watch out: committing a transaction that was already rolled back throws TransactionStateError. Only a second commit of an already-committed transaction is a harmless no-op.
The other way round throws too (since v1.2.0): manager.rollback(transaction, reason) on a transaction that is already committed throws TransactionStateError, matching Transaction.rollback(). Before v1.2.0 it silently did nothing, so a catch block that rolled back after a late failure looked as if it had undone the work. Rolling back an already rolled-back or failed transaction is still a harmless no-op, and run() no longer tries to roll back when an error (for example from an afterCommit hook) arrives after a successful commit.
ADAPTERS
An adapter is the object that turns "begin a transaction" into something your database understands. The manager never touches a driver; it calls adapter.begin(), adapter.commit(handle) and adapter.rollback(handle).
A handle is whatever your begin() returns — a client, a connection, an id. The manager stores it and hands it straight back to you on commit and rollback, without looking inside.
Application code can reach the handle too, so a repository can run its queries on "the connection of the current transaction". Since v1.2.0 getTransactionHandle<T>(transaction) returns the handle of a transaction you hold, and currentTransactionHandle<T>() or manager.getCurrentHandle<T>() the handle of whatever transaction is in scope. A participant resolves to the handle of the transaction it joined, a savepoint to its connection's handle, and a non-transactional scope (or no transaction at all) to undefined. Use the handle for work inside the transaction; commit and roll back through the manager, or you bypass its state machine, hooks and events.
currentTransactionHandle() reads the shared default context. If you gave the manager its own context, pass that context, or call manager.getCurrentHandle(). getTransactionHandle throws TypeError for an object this package did not create.
An adapter also declares its capabilities: whether it can do savepoints, nested transactions, read-only transactions, timeouts, and which isolation levels it accepts. Asking for something the adapter did not declare throws TransactionIsolationError (unsupported isolation level) or TransactionCapabilityError (any other capability) before any work starts.
This adapter records what it was asked to do instead of talking to a database, so you can see exactly which calls the manager makes.
A real adapter looks the same, with the log.push lines replaced by client.query("BEGIN") and friends. createAdapter(implementation, capabilities) is a small helper that takes an existing adapter and replaces its capability list with a frozen one.
Isolation levels
An isolation level says how much of other, still-unfinished transactions' work yours is allowed to see. The four levels, from most permissive to strictest, are read_uncommitted, read_committed, repeatable_read and serializable.
You ask for one per transaction with isolation. The adapter above accepts two of the four, so this throws:
NESTING AND PROPAGATION
Service functions call each other, and each of them may want a transaction. Propagation is the rule that decides what happens when one run() starts while another is already open.
The default is required: the inner call joins the transaction already in progress instead of opening a second one. What it receives is a participant — a handle that can read the transaction and mark it rollback-only, but can never commit it. The scope that opened the transaction is the only one that finishes it.
The manager finds the transaction in progress through Node's AsyncLocalStorage, so it travels down through every await without you passing anything around.
If the inner callback throws, the participant does not roll the whole thing back on the spot. It marks the enclosing transaction rollback-only, and the error travels up to whoever opened the transaction.
The seven modes
Pass one as propagation. Both columns matter — what a mode does when a transaction is in progress, and what it does when none is.
| Mode | Transaction in progress | None in progress |
|---|---|---|
required (default) | Joins it as a participant. | Opens a new one. |
requires_new | Suspends it and opens a separate one. | Opens a new one. |
nested | Opens a savepoint inside it. | Opens a new one. |
supports | Joins it as a participant. | Runs with no transaction. |
not_supported | Suspends it and runs with no transaction. | Runs with no transaction. |
mandatory | Joins it as a participant. | Throws TransactionPropagationError. |
never | Throws TransactionPropagationError. | Runs with no transaction. |
"Runs with no transaction" gives you a handle whose kind is "none": it moves through the same states so your code can treat it uniformly, but the adapter is never called. "Suspends" means the enclosing transaction is hidden for the duration, so anything nested inside sees nothing in progress.
Danger: requires_new opens a second, independent transaction while the first is still open. It commits on its own and its writes survive even if the outer transaction later rolls back. On a single-connection driver it can also deadlock against the transaction it suspended.
SAVEPOINTS
A savepoint is a bookmark inside a transaction. You can undo back to the bookmark without throwing away everything the transaction did before it.
That is what propagation: "nested" gives you. The inner scope gets a handle whose kind is "savepoint". If it fails, the manager rolls back to that savepoint only; if it succeeds, the savepoint is released and its work stays part of the outer transaction.
Here an optional step fails and the outer transaction still commits.
Nested transactions need an adapter that declares savepoints: true and implements createSavepoint. Without both, begin({ propagation: "nested" }) throws TransactionPropagationError. The in-memory adapter supports them.
ROLLBACK-ONLY AND TIMEOUTS
Sometimes you know a transaction must not be kept, but you are not the code that will finish it — you are three functions deep. Mark it rollback-only and carry on; whoever tries to commit it gets a rollback instead.
The flag is checked before the adapter is asked to commit, so a rollback can never be reported to you as a commit. The commit rejects with TransactionRollbackOnlyError (new in v1.2.0), a TransactionRollbackError subclass whose message reads Transaction "txn_…" commit refused: transaction marked rollback-only. The reason you gave is in error.metadata.originalError. Before v1.2.0 this was a plain TransactionRollbackError with the misleading message "rollback failed"; instanceof TransactionRollbackError and the error code still match.
Timeouts
timeout is a number of milliseconds. When it passes, the transaction is marked rollback-only, timedOut becomes true, and transaction.signal (an AbortSignal, new in v1.2.0) aborts with a TransactionTimeoutError as its reason. run() stops waiting for the callback at that moment: it rolls back and rejects with the TransactionTimeoutError straight away.
JavaScript cannot cancel a promise, so a callback that ignores the signal keeps running in the background after run() has rejected; its eventual result or error is discarded. Any side effect it has outside the transaction (an HTTP call, a queue message, a file) still happens. Pass transaction.signal to fetch, your driver, or anything else that accepts one, or use the database's own statement timeout. A participant exposes the signal of the transaction it joined, and a savepoint's signal also aborts with its parent's. raceSignal(work, signal) is the helper run() uses: it settles with work, or rejects with signal.reason as soon as the signal aborts.
Watch out: a timed-out transaction is rolled back and commit()/run() throw TransactionTimeoutError (transaction.timedOut is also true). Transactions opened with begin() time out too: their signal aborts and the later commit() rejects. The adapter must declare timeouts: true or the timeout option is rejected up front.
Changed in v1.2.0: transaction.timed_out is emitted once per timeout, from the timer. It used to be emitted a second time when the commit of a timed-out transaction was refused. Before v1.2.0 there was also no signal, and run() waited for the callback to finish before rolling back.
RETRIES
Some database failures are worth trying again: deadlocks and serialization failures happen because two transactions collided, and one of them usually succeeds on a second run.
A failed transaction cannot be resumed, only replayed, so a retry re-runs your whole callback in a brand new transaction. Write callbacks that are safe to run twice.
attempts counts retries on top of the first try, so attempts: 3 means up to four runs. delay is the wait in milliseconds; with backoff: "exponential" it doubles each time (50, 100, 200). Without shouldRetry, every failure of a transaction this run() opened is retried — including the constraint violations that will fail identically every time. An attempt that only joined an enclosing transaction is never replayed.
HOOKS AND REGISTRY
A hook is a function the manager calls at a fixed moment in a transaction's life: beforeBegin, afterBegin, beforeCommit, afterCommit, beforeRollback, afterRollback and onError. Hooks belong to the manager and run for every transaction it owns.
mergeHooks() combines several sets into one, running them in the order you passed them. A registry is a list of the transactions currently open, useful for a health endpoint or a "what is stuck?" log.
You should see begin place-order, then 1, then commit place-order, then 0. Participants are not registered separately — the registry tracks the transactions the manager actually owns.
Per-transaction callbacks
afterCommit() and afterRollback() on the transaction itself register callbacks for that one transaction. Use afterCommit for side effects that must not happen unless the data really landed, such as sending an email or publishing an event. Inside a nested block, releasing the savepoint is not a commit: its callbacks (and hooks.afterCommit for the savepoint) move to the enclosing transaction and run only when the outermost transaction commits, or for afterRollback, rolls back.
API REFERENCE
Everything below is exported from @zudojs/transactions. Most apps only need createTransactionManager, an adapter, and manager.run().
Functions
| Name | What it does | Notes |
|---|---|---|
createTransactionManager({ adapter, context?, hooks?, registry?, onEvent? }) | Builds the manager you call from app code. | adapter is required; the context defaults to a shared AsyncLocalStorage one. onEvent receives a TransactionEvent for each lifecycle step. |
createInMemoryAdapter() | Adapter that keeps handles in memory and emulates savepoints. | For tests and examples. Declares every capability. |
createAdapter(implementation, capabilities) | Copies an adapter with a frozen capability list. | Handy when one driver is used at two capability levels. |
createTransactionContext() | A fresh AsyncLocalStorage-backed context. | Pass as context to isolate a manager, as the tests do. |
getDefaultContext() / resetDefaultContext() | Read or discard the shared default context. | reset is for tests. |
createTransactionRegistry() | In-memory list of open transactions. | Pass as registry; read with getActive(). |
mergeHooks(...hookSets) | Combines hook objects into one. | Earlier sets run first for each event. |
createTransaction(options?, parentId?, kind?) | Builds a bare transaction in pending state. | The manager calls this for you. Direct use is rare. |
createParticipant(parent) / createNonTransactional(options?) | The handles required and not_supported hand back. | Exposed for custom propagation logic. |
isTerminalState(state) | true for committed, rolled_back, failed. | Works on a state string. |
isModifiable(transaction) | true while the state is active or pending. | Takes a transaction, not a state. |
summarizeTransaction(transaction) | One-line summary for logs. | e.g. "Transaction txn_ab12, state=active, duration=42ms". |
canTransition(from, to) / isTerminal(state) / createTransitionFunction(get, set) | The state machine rules, on their own. | For custom Transaction implementations. |
getTransactionHandle<T>(transaction) / currentTransactionHandle<T>(context?) | The adapter handle behind a transaction, or behind the one in scope. | New in v1.2.0. undefined outside a transaction or in a non-transactional scope. |
raceSignal(work, signal) | Settles with work, or rejects with signal.reason once signal aborts. | New in v1.2.0. The abandoned promise keeps running; its rejection is observed. |
asSavepointHandle(handle) | Narrows a handle to { parent, savepoint }, or undefined. | For adapters that inspect nested handles. |
Manager methods
| Name | What it does | Notes |
|---|---|---|
run<T>(callback, options?) | Opens a transaction, runs the callback, commits or rolls back. | Returns whatever the callback returns. Honours retry. |
begin(options?) | Opens a transaction and returns it. | You must commit or roll it back yourself. |
commit(transaction) | Commits it, or rolls back if it is rollback-only. | No-op for participants and already-committed transactions; throws otherwise when not active. |
rollback(transaction, reason?) | Rolls back, or marks the joined transaction rollback-only. | Throws TransactionStateError on a committed transaction (since v1.2.0); already rolled-back or failed ones are a no-op. |
getCurrent() | The transaction in scope right now, or undefined. | Reads the async context. |
getCurrentHandle<T>() | The adapter handle of the transaction in scope, or undefined. | New in v1.2.0. Uses this manager's context. |
Transaction members
| Name | What it does | Notes |
|---|---|---|
id, parentId, startedAt | Identity and start time. | id looks like txn_ab12…; a participant shares the id it joined. |
kind | "root", "participant", "savepoint" or "none". | How the manager routes commit and rollback. |
state, timedOut | Lifecycle state and whether the timeout fired. | See the state table above. |
signal | An AbortSignal that aborts with a TransactionTimeoutError when the transaction times out. | New in v1.2.0. Never aborts without a timeout. Participants share the joined transaction's signal. |
options, metadata | The options it was opened with; metadata as a map. | metadata hands back a copy, so writing to it changes nothing. |
markRollbackOnly(reason?) / isRollbackOnly() | Forbid the commit / ask whether it was forbidden. | On a participant, both apply to the joined transaction. |
afterCommit(cb) / afterRollback(cb) | Run a callback once this transaction ends that way. | The other list is discarded, so exactly one set runs. |
commit() / rollback(reason?) | Move the transaction's own state. | Call manager.commit() instead — these do not touch the adapter. |
Options
| Name | What it does | Notes |
|---|---|---|
propagation | What to do when a transaction is already open. | Default "required". See the table above. |
isolation | Isolation level to ask the adapter for. | Must appear in the adapter's isolationLevels. |
timeout | Milliseconds before the transaction is marked rollback-only. | 0 or omitted means no timeout. Needs timeouts: true. |
readOnly | Asks the adapter for a read-only transaction. | Needs readOnlyTransactions: true. |
name, metadata | Labels for logs and hooks. | Never interpreted by the package. |
retry | { attempts?, delay?, backoff?, shouldRetry? }. | Only honoured by run(), not by begin(). |
Errors
| Name | What it does | Notes |
|---|---|---|
TransactionError | Base class for every error here. | Owned by @zudojs/errors and re-exported here (all 13 classes), so instanceof matches either import. |
TransactionStateError | The transaction is in the wrong state for what you asked. | Thrown by commit, rollback and illegal transitions. |
TransactionRollbackError | The commit turned into a rollback, or the rollback itself failed. | A driver failure during rollback is in cause. Accepts an optional message option. |
TransactionRollbackOnlyError | A commit was refused because the transaction was marked rollback-only (by markRollbackOnly or a failing participant). | New in v1.2.0; extends TransactionRollbackError. Message: commit refused: transaction marked rollback-only. The reason is in error.metadata.originalError. |
TransactionCommitError | The adapter refused the commit. | The driver error is in cause. |
TransactionAdapterError | The adapter misbehaved. | The in-memory adapter throws it for a foreign handle or an unknown savepoint. A missing capability is TransactionIsolationError / TransactionCapabilityError. |
TransactionPropagationError | A propagation rule was broken. | mandatory with nothing open, never with something open, an unknown mode. |
TransactionTimeoutError, TransactionIsolationError, TransactionCapabilityError, SavepointError | A timed-out transaction (also transaction.signal.reason); an isolation level or other capability the adapter did not declare; a savepoint that could not be released or rolled back to. | Thrown by the manager. |
TransactionRequiredError, TransactionUnexpectedError | Extra error classes you can throw from your own adapters and services. | The package itself never throws these. |
Types and constants
| Name | What it does | Notes |
|---|---|---|
Transaction, TransactionOptions, TransactionRetryOptions | The handle you are given and the options you pass. | Type-only exports. |
TransactionState, TransactionPropagation, TransactionIsolationLevel | The string unions used above. | Useful for typing your own wrappers. |
TransactionAdapter, TransactionAdapterCapabilities, TransactionHandle | The adapter contract. | Implement the first; TransactionHandle is unknown. |
TransactionContext | Contract for async propagation: get, run, exit. | A custom context must implement exit. |
TransactionHooks, TransactionHookContext, TransactionErrorContext | Hook shapes. | Hooks receive { transaction }, onError also { error }. |
TransactionRegistry | Contract for a registry. | Implement it to publish open transactions elsewhere. |
Savepoint, TransactionResult | Shapes for savepoint objects and run results. | Declarations only — nothing in the package returns one. |
TRANSACTION_EVENTS, TransactionEvent, TransactionEventHandler | Names and shapes for transaction lifecycle events. | Pass onEvent to createTransactionManager to receive them. A throwing handler is ignored. A timeout is reported once (see Timeouts). |
Transaction internals (_setHandle, _transition, _markTimedOut) live behind a private symbol and are not reachable from the package's exports. Drive the state machine through the manager.
COMMON MISTAKES
-
Committing the handle an inner
run()gave you. With the defaultrequiredpropagation that handle is a participant, somanager.commit(inner)does nothing and the outer transaction is still open. Let the scope that opened the transaction finish it. -
Swallowing an inner failure and committing anyway. A failing participant marks the enclosing transaction rollback-only, so the outer
commit()rejects withTransactionRollbackErroreven though you caught the error. If a step is genuinely optional, run it withpropagation: "nested"so only its savepoint is undone. -
Sending the email inside the callback. If the commit later fails, the mail is already gone. Register it with
transaction.afterCommit()so it only runs once the data is really saved. -
Retrying everything. Without
shouldRetry, a constraint violation is replayed until the attempts run out — slower, and the error is the same each time. Returntrueonly for deadlocks and serialization failures. -
Ignoring
transaction.signal. On a timeoutrun()rolls back and rejects at once, but a callback that does not watch the signal keeps running in the background and its side effects still happen. Passtransaction.signalto cancellable work. -
Declaring capabilities the adapter does not have. Claiming
savepoints: truewithout implementingcreateSavepointmakespropagation: "nested"throw; claiming isolation levels the driver ignores means aserializableunit of work silently runs at the default. Declare only what you implement. -
Reaching for
requires_newto "just commit this bit". It opens a second transaction on top of the first. On a single-connection driver the two can deadlock, and its writes survive the outer rollback. Usenestedunless you truly want independent work.
COMPLETE EXPORT INDEX
Every name @zudojs/transactions exports from its package root at v1.1.2 — 54 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.
Show all 54 exports
SavepointError TransactionAdapterError TransactionCapabilityError TransactionCommitError TransactionError TransactionIsolationError TransactionPropagationError TransactionRequiredError TransactionRollbackError TransactionStateError TransactionTimeoutError TransactionUnexpectedErrorasSavepointHandle canTransition createAdapter createEmitter createInMemoryAdapter createNonTransactional createParticipant createTransaction createTransactionContext createTransactionManager createTransactionRegistry createTransitionFunction getDefaultContext isModifiable isTerminal isTerminalState mergeHooks resetDefaultContext summarizeTransactionSavepointHandle Transaction TransactionAdapter TransactionAdapterCapabilities TransactionContext TransactionErrorContext TransactionErrorOptions TransactionEvent TransactionHookContext TransactionHooks TransactionManagerOptions TransactionOptions TransactionRegistry TransactionRetryOptionsTransactionEmitter TransactionEventHandler TransactionHandle TransactionIsolationLevel TransactionKind TransactionPropagation TransactionRetryPredicate TransactionStateTRANSACTION_EVENTS