@spfn/core/db/transaction
Database transactions with AsyncLocalStorage-based context propagation. A transaction
started anywhere (the Transactional Hono middleware, or runInTransaction in a script)
is automatically picked up by every BaseRepository operation in the async call chain —
no need to thread a tx argument through your code.
Import paths
All transaction symbols are re-exported from @spfn/core/db. There is no
@spfn/core/db/transaction package export subpath and no @spfn/core root export —
importing from those paths fails to resolve.
import {
Transactional,
runInTransaction,
onAfterCommit,
getTransaction,
getTransactionContext,
runWithTransaction,
} from '@spfn/core/db';
import type {
TransactionDB,
TransactionContext,
TransactionalOptions,
RunInTransactionOptions,
AfterCommitCallback,
} from '@spfn/core/db';
Public API (complete)
From @spfn/core/db:
Transactional(options?)— Hono middleware; wraps a route handler in a transaction.runInTransaction(callback, options?)— run a callback in a transaction (scripts/CLI; no Hono).onAfterCommit(callback)— defer a side effect until after the root transaction commits.getTransaction()— current transactionTransactionDB, ornull.getTransactionContext()— currentTransactionContext(tx,txId,level, callbacks), ornull.runWithTransaction(tx, txId, callback)— bind atxintoAsyncLocalStoragefor a callback.- Types:
TransactionDB,TransactionContext,TransactionalOptions,RunInTransactionOptions,AfterCommitCallback.
getTransactionId()andasyncContextexist incontext.tsbut are not exported from the module index — do not import them. UsegetTransactionContext()?.txIdif you need the ID.
runWithTransactiontakes three arguments:(tx, txId, callback). Older docs that call it asrunWithTransaction(tx, callback)are wrong and will mis-bind the callback as thetxIdstring.
How propagation works (read this first)
Transactional and runInTransaction both go through runInTransaction, which:
- Resolves the write DB (
getDatabase('write')). - Opens
writeDb.transaction(...). - Calls
runWithTransaction(tx, txId, callback)to store{ tx, txId, level, afterCommitCallbacks }in a globalAsyncLocalStorage.
Inside that callback, any code that calls getTransaction() gets the live tx. The key
consumer is BaseRepository: its db and readDb getters call getTransaction()
first and use the transaction if present, otherwise fall back to the global write/read
instance:
// BaseRepository (simplified)
protected get db() { return getTransaction() ?? getDatabase('write'); }
protected get readDb() { return getTransaction() ?? getDatabase('read'); }
Consequence: inside a transaction you write normal repository code — userRepo.create(...),
postRepo.findById(...) — and they all join the same transaction automatically. You almost
never call getTransaction() yourself.
Inside a transaction,
readDbalso resolves to the transaction connection (not the read replica). Reads within a tx see its own uncommitted writes and run on the primary.
Quick Start
Route middleware
import { route } from '@spfn/core/route';
import { Transactional } from '@spfn/core/db';
import { Type } from '@sinclair/typebox';
export const createUser = route.post('/users')
.input({ body: Type.Object({ email: Type.String(), name: Type.String() }) })
.use([Transactional()])
.handler(async (c) =>
{
const { body } = await c.data();
// Both repo calls automatically join the same transaction.
const user = await userRepo.create(body);
await profileRepo.create({ userId: user.id, bio: 'New user' });
return user; // success → commit
// any throw → rollback
});
Script / CLI
import { runInTransaction } from '@spfn/core/db';
await runInTransaction(async (tx) =>
{
// `tx` is passed in AND available via getTransaction() to nested repo calls.
const [user] = await tx.insert(users).values({ name: 'John' }).returning();
await profileRepo.create({ userId: user.id }); // also joins this tx
return user;
}, { context: 'script:seed-user', timeout: 60000 });
Transactional(options?) — Hono middleware
Wraps the downstream handler chain in a transaction. Commits when the handler resolves,
rolls back when it throws or when Hono stored an error on the context (c.error).
.use([Transactional({
slowThreshold: 2000, // warn if the tx runs longer than 2s (default 1000)
enableLogging: false, // silence per-tx logs (default true)
timeout: 60000, // PG statement_timeout in ms (default 30000 / TRANSACTION_TIMEOUT)
})])
Error conversion (rollback path)
On rollback the middleware re-throws, but normalizes the error first:
- Reports the error to the DB reconnect-trigger (no-op for non-connection errors).
DatabaseErrorandTransactionErrorinstances are re-thrown unchanged.- An object with a string
code(a raw PostgreSQL error) is converted viafromPostgresError(...)→DuplicateEntryError,ConstraintViolationError,DeadlockError,ConnectionError,TransactionError, orQueryError. - Anything else (e.g. business-logic errors like
InvalidCredentialsError) is re-thrown as-is.
TransactionalOptions
| Option | Type | Default | Notes |
|---|---|---|---|
slowThreshold |
number |
1000 |
ms; logs a warn if commit/rollback exceeds it |
enableLogging |
boolean |
true |
start/commit/rollback debug logs |
timeout |
number |
30000 / env.TRANSACTION_TIMEOUT |
PostgreSQL statement_timeout in ms |
runInTransaction(callback, options?)
The engine under both the middleware and standalone use. callback receives the Drizzle
tx; the same tx is also available via getTransaction() to anything it calls.
function runInTransaction<T>(
callback: (tx: TransactionDB) => Promise<T>,
options?: RunInTransactionOptions,
): Promise<T>;
RunInTransactionOptions
| Option | Type | Default | Notes |
|---|---|---|---|
slowThreshold |
number |
1000 |
ms; must be a non-negative integer or it throws TransactionError |
enableLogging |
boolean |
true |
|
timeout |
number |
env.TRANSACTION_TIMEOUT (30000) |
see timeout semantics below |
context |
string |
'transaction' |
label in logs (the middleware passes "METHOD /path") |
Timeout semantics
timeout becomes a SET LOCAL statement_timeout = <ms> issued at the start of the root
transaction (via sql.raw, since SET can't be parameterized). Resolution order is
options.timeout ?? env.TRANSACTION_TIMEOUT:
timeout: 0— disables the timeout (unlimited).null/undefined— falls back toenv.TRANSACTION_TIMEOUT(default 30000).N— must be an integer in0 … 2147483647(PG max int4). Out-of-range / non-integer values throwTransactionError(status 400) before any DB access.
Timeout is applied only to root transactions. In a nested call the timeout is ignored
(and a warn is logged), because SET LOCAL would re-scope the entire outer transaction.
idleTimeout is the companion knob: SET LOCAL idle_in_transaction_session_timeout = <ms>,
also root-only, resolved as options.idleTimeout ?? env.TRANSACTION_IDLE_TIMEOUT (default
30000). Where statement_timeout bounds a single query's run time, this bounds how long the
transaction may sit idle (no query running) — e.g. while the handler awaits external I/O.
On expiry Postgres terminates the session and rolls back, reclaiming the pooled connection
instead of letting one stuck request hold it (and its row locks) indefinitely. 0 disables it.
This is a backstop, not a license — see the anti-pattern below.
Validation / errors
runInTransaction fails fast with a TransactionError (before opening a transaction) when:
callback is not a function; slowThreshold is negative/non-integer; timeout is
non-integer, negative, or above the max; or the write database is not initialized
(status 500). Errors thrown by callback itself propagate unchanged after rollback.
Nested transactions (SAVEPOINTs)
Nesting is supported. When runInTransaction / Transactional runs while an
AsyncLocalStorage context already exists, Drizzle's tx.transaction() issues a
PostgreSQL SAVEPOINT, and runWithTransaction increments level (root = 1).
await runInTransaction(async () => // level 1 (root, real BEGIN)
{
await userRepo.create(...);
await runInTransaction(async () => // level 2 (SAVEPOINT)
{
await postRepo.create(...);
throw new Error('inner'); // rolls back to the SAVEPOINT only
});
}); // root still commits its own work
Behavior to know:
- The inner level shares the root's
afterCommitCallbacksqueue, soonAfterCommitregistered in a nested call fires after the root commits, not the savepoint. timeoutpassed to a nested call is ignored (warning logged); the root's timeout governs.- An inner rollback unwinds to its savepoint; the outer can catch and continue.
onAfterCommit(callback)
Defer a side effect (notifications, jobs, analytics, cache busting) until after the data
is durably committed. AfterCommitCallback = () => void | Promise<void>.
import { onAfterCommit } from '@spfn/core/db';
async function submit(spaceId: string, chatId: string)
{
const publication = await publicationRepo.create({ spaceId, chatId });
await requestRepo.updateStatusAtomically(requestId, 'submitted');
onAfterCommit(() => generateArticle(spaceId, chatId, publication.id));
return publication;
}
| Context | Behavior |
|---|---|
| Inside root transaction | Queued; fires after the root commits |
| Inside nested (SAVEPOINT) | Queued on the root queue; fires after the root commits |
| Outside any transaction | Fires immediately on a microtask (already "committed") |
- Callbacks run outside the transaction context — a
getTransaction()inside one returnsnull, so DB work uses a fresh connection (a new transaction, not this one). - Fire-and-forget: each callback runs via
Promise.resolve().then(cb).catch(log). Errors are logged, never thrown, and never affect the (already committed) transaction. - Execution is FIFO in registration order; multiple callbacks per transaction are fine.
- If the transaction rolls back, queued callbacks never run (they're only collected on the success path).
Low-level: getTransaction / getTransactionContext / runWithTransaction
You normally don't touch these — BaseRepository already resolves the transaction for you.
Reach for them only when writing a custom wrapper, or for a repository-less helper that must
manually honor an ambient transaction.
import { getTransaction, getTransactionContext, runWithTransaction } from '@spfn/core/db';
getTransaction(); // TransactionDB | null
getTransactionContext(); // { tx, txId, level, afterCommitCallbacks } | null
// Bind an existing Drizzle tx so nested code sees it via getTransaction().
// NOTE the 3-arg signature: (tx, txId, callback)
await db.transaction(async (tx) =>
{
return await runWithTransaction(tx, `tx_${crypto.randomUUID()}`, async () =>
{
// getTransaction() === tx here and in everything this calls
return doWork();
});
});
Prefer
runInTransactionover hand-rollingdb.transaction()+runWithTransaction: the runner adds thetxId, timeout enforcement, slow-tx logging, and theonAfterCommitqueue plumbing that rawrunWithTransactiondoes not.
Pitfalls & anti-patterns
- Never do external I/O inside a transaction. A transaction holds a pooled connection
(and any row locks taken) from
BEGINtoCOMMIT. If the handler awaits an external API, queue, or other non-DB work while the transaction is open, that connection sits idle but reserved — under load, in-flight requests cap out at the pool size and everything else queues. Route-levelTransactional()wraps the whole handler, so it's especially easy to fall into; prefer scoping the transaction to the DB statements (callrunInTransactioninside a service around just the writes). Theidle_in_transaction_session_timeoutbackstop reaps the worst case, and a "Slow transaction"warnflags offenders — but the fix is to move the I/O out. If you have a write → external-call → write flow where the external call has a side effect (charge, send), don't span it with a transaction at all — commit intent, call outside the transaction, then commit the result (outbox / saga). - Import from
@spfn/core/db, not@spfn/core/db/transactionor@spfn/core. Neither of the latter is a real package export — they don't resolve. runWithTransactionis(tx, txId, callback). Calling it with two args silently binds your callback into thetxIdslot. If you only have repositories, you don't need this function at all — start a transaction withrunInTransaction/Transactional.- Don't pass
txaround manually when using repositories. Repositories read the ambient transaction viagetTransaction(). Threading atxparameter is redundant and invites bugs where one path forgets it. - Don't nest a raw
db.transaction()withoutrunWithTransaction. A rawdb.transaction()opens a Drizzle SAVEPOINT but does not updateAsyncLocalStorage, so repositories inside it still use the outer tx, not the savepoint — your "inner" rollback won't isolate as expected. UserunInTransaction(which wires both) instead. timeouton a nested call is ignored. Set the timeout on the outermost transaction/middleware; nested values are dropped with a warning.- Side effects belong in
onAfterCommit, not the handler body. Calling a notifier / job / external API directly in the handler runs it before commit and holds the transaction (and its pooled connection) open. If the tx later rolls back, you've already fired the side effect. onAfterCommitDB work runs in a new connection, not this transaction. It executes after the context is gone, sogetTransaction()isnullinside it — its writes are a separate transaction and won't roll back with the original.- Keep transactions short. No network I/O / file uploads inside the tx — do that work first, then run only the DB writes in the transaction (see example below). Long transactions hold connections and can exhaust the pool.
getTransaction()returnsnulloutside a transaction. It is not a "get me a db" helper. For plain DB access use aBaseRepositoryorgetDatabase().
// ❌ Long transaction: external work holds the tx + connection open
.use([Transactional()])
.handler(async (c) =>
{
const data = await fetch('https://api.example.com').then(r => r.json()); // I/O in tx
await userRepo.create(data);
await uploadFile(file); // I/O in tx
return { ok: true };
});
// ✅ External work first, DB writes only inside the transaction
.handler(async (c) =>
{
const data = await fetch('https://api.example.com').then(r => r.json());
await uploadFile(file);
return runInTransaction(async () =>
{
const user = await userRepo.create(data);
onAfterCommit(() => notify(user.id)); // side effect after commit
return user;
});
});
Complete example
// routes/publications.ts
import { route } from '@spfn/core/route';
import { Transactional, onAfterCommit } from '@spfn/core/db';
import { Type } from '@sinclair/typebox';
export const submit = route.post('/publications')
.input({ body: Type.Object({ spaceId: Type.String(), chatId: Type.String() }) })
.use([Transactional({ timeout: 15000, slowThreshold: 2000 })])
.handler(async (c) =>
{
const { body } = await c.data();
// Repositories auto-join the middleware's transaction.
const publication = await publicationRepo.create(body);
await requestRepo.updateStatusAtomically(body.chatId, 'submitted');
// Fires only after the transaction commits; runs on a fresh connection.
onAfterCommit(() => generateArticle(body.spaceId, body.chatId, publication.id));
return publication; // commit; a throw here → rollback (PG errors normalized)
});
// scripts/backfill.ts — same propagation, no Hono
import { runInTransaction } from '@spfn/core/db';
await runInTransaction(async (tx) =>
{
const rows = await tx.select().from(legacy);
for (const row of rows)
{
await userRepo.create(mapLegacy(row)); // joins this tx via getTransaction()
}
}, { context: 'script:backfill', timeout: 0 /* disable timeout for a long backfill */ });
Types reference
type TransactionDB = PostgresJsDatabase<Record<string, unknown>>;
type AfterCommitCallback = () => void | Promise<void>;
type TransactionContext = {
tx: TransactionDB; // live Drizzle transaction
txId: string; // "tx_<uuid>" — tracing id
level: number; // nesting depth, root = 1
afterCommitCallbacks: AfterCommitCallback[]; // shared with the root
};
interface TransactionalOptions {
slowThreshold?: number; // default 1000 (ms)
enableLogging?: boolean; // default true
timeout?: number; // default 30000 / env.TRANSACTION_TIMEOUT (ms)
}
interface RunInTransactionOptions {
slowThreshold?: number; // default 1000 (ms)
enableLogging?: boolean; // default true
timeout?: number; // default env.TRANSACTION_TIMEOUT (30000 ms)
context?: string; // default 'transaction'
}
Related
- @spfn/core/db — database connection,
BaseRepository,getDatabase - @spfn/core/config —
TRANSACTION_TIMEOUTand other settings - @spfn/core/errors —
TransactionError,DatabaseError,fromPostgresErrorresults