Docs / Packages / @zudojs/transactions
v1.2.0

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

TRANSACTIONS COMMIT ROLLBACK SAVEPOINTS RETRY

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.

$ npm install @zudojs/transactions

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.

import { createTransactionManager, createInMemoryAdapter, } from "@zudojs/transactions"; const manager = createTransactionManager({ adapter: createInMemoryAdapter(), }); const orderId = await manager.run(async (transaction) => { console.log(transaction.state); // "active" console.log(transaction.kind); // "root" // your inserts and updates go here return "ord_1"; }); console.log(orderId); // "ord_1"

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.

import { createTransactionManager, createInMemoryAdapter, } from "@zudojs/transactions"; const manager = createTransactionManager({ adapter: createInMemoryAdapter(), }); async function insertOrder() { console.log("insert order"); } async function insertLineItems() { console.log("insert line items"); } async function decreaseStock() { throw new Error("out of stock"); } try { await manager.run(async () => { await insertOrder(); await insertLineItems(); await decreaseStock(); }, { name: "place-order" }); } catch (error) { console.log((error as Error).message); // "out of stock" }

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.

const transaction = await manager.begin({ name: "manual" }); try { // your writes go here await manager.commit(transaction); } catch (error) { await manager.rollback(transaction, error); } console.log(transaction.state); // "committed"

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.

StateWhat it meansCan move to
pendingCreated, but the adapter has not opened it yet.active, rolling_back, failed
activeOpen. This is where your writes happen.committing, rolling_back, failed
committingThe commit is under way.committed, rolling_back, failed
committedDone and kept. Nothing follows.
rolling_backThe rollback is under way.rolled_back, failed
rolled_backDone and thrown away. Nothing follows.
failedThe 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.

import { createTransactionManager, createInMemoryAdapter, currentTransactionHandle, getTransactionHandle, } from "@zudojs/transactions"; const manager = createTransactionManager({ adapter: createInMemoryAdapter() }); // A repository deep in the call stack finds the transaction's connection itself. function connectionForQuery(): unknown { return currentTransactionHandle() ?? "the pool"; } await manager.run(async (transaction) => { console.log(connectionForQuery() === getTransactionHandle(transaction)); // true console.log(manager.getCurrentHandle() === getTransactionHandle(transaction)); // true }); console.log(connectionForQuery()); // "the pool"

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.

import { createTransactionManager } from "@zudojs/transactions"; import type { TransactionAdapter, TransactionHandle, } from "@zudojs/transactions"; const log: string[] = []; const adapter: TransactionAdapter = { capabilities: { savepoints: true, nestedTransactions: true, isolationLevels: ["read_committed", "serializable"], readOnlyTransactions: true, timeouts: true, }, async begin(): Promise<TransactionHandle> { log.push("BEGIN"); return { connection: log.length }; }, async commit() { log.push("COMMIT"); }, async rollback() { log.push("ROLLBACK"); }, async createSavepoint(_handle, name) { log.push(`SAVEPOINT ${name}`); }, async rollbackToSavepoint(_handle, name) { log.push(`ROLLBACK TO ${name}`); }, async releaseSavepoint(_handle, name) { log.push(`RELEASE ${name}`); }, }; const manager = createTransactionManager({ adapter }); await manager.run(async () => "done"); console.log(log); // [ 'BEGIN', 'COMMIT' ]

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:

await manager.begin({ isolation: "repeatable_read" }); // TransactionIsolationError: Isolation level "repeatable_read" is not supported by the adapter

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.

import { createTransactionManager, createInMemoryAdapter, } from "@zudojs/transactions"; const manager = createTransactionManager({ adapter: createInMemoryAdapter(), }); await manager.run(async (outer) => { await manager.run(async (inner) => { console.log(inner.kind); // "participant" console.log(inner.id === outer.id); // true }); console.log(outer.state); // "active" — the inner run did not commit it });

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.

ModeTransaction in progressNone in progress
required (default)Joins it as a participant.Opens a new one.
requires_newSuspends it and opens a separate one.Opens a new one.
nestedOpens a savepoint inside it.Opens a new one.
supportsJoins it as a participant.Runs with no transaction.
not_supportedSuspends it and runs with no transaction.Runs with no transaction.
mandatoryJoins it as a participant.Throws TransactionPropagationError.
neverThrows 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.

import { createTransactionManager, createInMemoryAdapter, } from "@zudojs/transactions"; const manager = createTransactionManager({ adapter: createInMemoryAdapter(), }); await manager.run(async (outer) => { try { await manager.run( async (child) => { console.log(child.kind); // "savepoint" console.log(child.parentId === outer.id); // true throw new Error("coupon rejected"); }, { propagation: "nested" }, ); } catch (error) { console.log((error as Error).message); // "coupon rejected" } console.log(outer.isRollbackOnly()); // false — the outer work survived });

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.

import { createTransactionManager, createInMemoryAdapter, TransactionRollbackOnlyError, } from "@zudojs/transactions"; const manager = createTransactionManager({ adapter: createInMemoryAdapter(), }); const transaction = await manager.begin(); transaction.markRollbackOnly("stock check failed"); try { await manager.commit(transaction); } catch (error) { console.log(error instanceof TransactionRollbackOnlyError); // true console.log((error as Error).message); // Transaction "txn_…" commit refused: transaction marked rollback-only } console.log(transaction.state); // "rolled_back"

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.

import { createTransactionManager, createInMemoryAdapter, TransactionTimeoutError, } from "@zudojs/transactions"; const manager = createTransactionManager({ adapter: createInMemoryAdapter() }); const started = Date.now(); try { await manager.run(async (transaction) => { // Hand the signal to cancellable work so it stops too. await new Promise((resolve, reject) => { const timer = setTimeout(resolve, 300); transaction.signal.addEventListener("abort", () => { clearTimeout(timer); reject(transaction.signal.reason); }); }); }, { timeout: 20 }); } catch (error) { console.log(error instanceof TransactionTimeoutError, Date.now() - started < 100); // true true }

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.

import { createTransactionManager, createInMemoryAdapter, } from "@zudojs/transactions"; const manager = createTransactionManager({ adapter: createInMemoryAdapter(), }); let tries = 0; const result = await manager.run( async () => { tries++; if (tries < 3) throw new Error("deadlock detected"); return "ok"; }, { retry: { attempts: 3, delay: 50, backoff: "exponential", shouldRetry: (error) => (error as Error).message.includes("deadlock"), }, }, ); console.log(result, tries); // "ok" 3

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.

import { createTransactionManager, createInMemoryAdapter, createTransactionRegistry, mergeHooks, } from "@zudojs/transactions"; import type { TransactionHooks } from "@zudojs/transactions"; const logging: TransactionHooks = { async afterBegin({ transaction }) { console.log("begin", transaction.options.name); }, async afterCommit({ transaction }) { console.log("commit", transaction.options.name); }, }; const auditing: TransactionHooks = { async onError({ error }) { console.error("transaction error", error); }, }; const registry = createTransactionRegistry(); const manager = createTransactionManager({ adapter: createInMemoryAdapter(), hooks: mergeHooks(logging, auditing), registry, }); await manager.run(async () => { console.log(registry.getActive().length); // 1 }, { name: "place-order" }); console.log(registry.getActive().length); // 0

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.

await manager.run(async (transaction) => { transaction.afterCommit(async () => { console.log("order confirmation sent"); }); }); // prints "order confirmation sent" after the commit succeeds

API REFERENCE

Everything below is exported from @zudojs/transactions. Most apps only need createTransactionManager, an adapter, and manager.run().

Functions

NameWhat it doesNotes
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

NameWhat it doesNotes
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

NameWhat it doesNotes
id, parentId, startedAtIdentity 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, timedOutLifecycle state and whether the timeout fired.See the state table above.
signalAn 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, metadataThe 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

NameWhat it doesNotes
propagationWhat to do when a transaction is already open.Default "required". See the table above.
isolationIsolation level to ask the adapter for.Must appear in the adapter's isolationLevels.
timeoutMilliseconds before the transaction is marked rollback-only.0 or omitted means no timeout. Needs timeouts: true.
readOnlyAsks the adapter for a read-only transaction.Needs readOnlyTransactions: true.
name, metadataLabels for logs and hooks.Never interpreted by the package.
retry{ attempts?, delay?, backoff?, shouldRetry? }.Only honoured by run(), not by begin().

Errors

NameWhat it doesNotes
TransactionErrorBase class for every error here.Owned by @zudojs/errors and re-exported here (all 13 classes), so instanceof matches either import.
TransactionStateErrorThe transaction is in the wrong state for what you asked.Thrown by commit, rollback and illegal transitions.
TransactionRollbackErrorThe commit turned into a rollback, or the rollback itself failed.A driver failure during rollback is in cause. Accepts an optional message option.
TransactionRollbackOnlyErrorA 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.
TransactionCommitErrorThe adapter refused the commit.The driver error is in cause.
TransactionAdapterErrorThe adapter misbehaved.The in-memory adapter throws it for a foreign handle or an unknown savepoint. A missing capability is TransactionIsolationError / TransactionCapabilityError.
TransactionPropagationErrorA propagation rule was broken.mandatory with nothing open, never with something open, an unknown mode.
TransactionTimeoutError, TransactionIsolationError, TransactionCapabilityError, SavepointErrorA 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, TransactionUnexpectedErrorExtra error classes you can throw from your own adapters and services.The package itself never throws these.

Types and constants

NameWhat it doesNotes
Transaction, TransactionOptions, TransactionRetryOptionsThe handle you are given and the options you pass.Type-only exports.
TransactionState, TransactionPropagation, TransactionIsolationLevelThe string unions used above.Useful for typing your own wrappers.
TransactionAdapter, TransactionAdapterCapabilities, TransactionHandleThe adapter contract.Implement the first; TransactionHandle is unknown.
TransactionContextContract for async propagation: get, run, exit.A custom context must implement exit.
TransactionHooks, TransactionHookContext, TransactionErrorContextHook shapes.Hooks receive { transaction }, onError also { error }.
TransactionRegistryContract for a registry.Implement it to publish open transactions elsewhere.
Savepoint, TransactionResultShapes for savepoint objects and run results.Declarations only — nothing in the package returns one.
TRANSACTION_EVENTS, TransactionEvent, TransactionEventHandlerNames 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 default required propagation that handle is a participant, so manager.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 with TransactionRollbackError even though you caught the error. If a step is genuinely optional, run it with propagation: "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. Return true only for deadlocks and serialization failures.

  • Ignoring transaction.signal. On a timeout run() 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. Pass transaction.signal to cancellable work.

  • Declaring capabilities the adapter does not have. Claiming savepoints: true without implementing createSavepoint makes propagation: "nested" throw; claiming isolation levels the driver ignores means a serializable unit of work silently runs at the default. Declare only what you implement.

  • Reaching for requires_new to "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. Use nested unless 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
Classes (12)
SavepointError TransactionAdapterError TransactionCapabilityError TransactionCommitError TransactionError TransactionIsolationError TransactionPropagationError TransactionRequiredError TransactionRollbackError TransactionStateError TransactionTimeoutError TransactionUnexpectedError
Functions (19)
asSavepointHandle canTransition createAdapter createEmitter createInMemoryAdapter createNonTransactional createParticipant createTransaction createTransactionContext createTransactionManager createTransactionRegistry createTransitionFunction getDefaultContext isModifiable isTerminal isTerminalState mergeHooks resetDefaultContext summarizeTransaction
Interfaces (14)
SavepointHandle Transaction TransactionAdapter TransactionAdapterCapabilities TransactionContext TransactionErrorContext TransactionErrorOptions TransactionEvent TransactionHookContext TransactionHooks TransactionManagerOptions TransactionOptions TransactionRegistry TransactionRetryOptions
Type aliases (8)
TransactionEmitter TransactionEventHandler TransactionHandle TransactionIsolationLevel TransactionKind TransactionPropagation TransactionRetryPredicate TransactionState
Constants (1)
TRANSACTION_EVENTS