@spfn/core 0.3.0-beta.5 → 0.3.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,822 @@
1
+ # @spfn/core/db/transaction — Transaction management & AsyncLocalStorage propagation
2
+
3
+ Database transactions with `AsyncLocalStorage`-based context propagation. A transaction
4
+ started anywhere (the `Transactional` Hono middleware, or `runInTransaction` in a script)
5
+ is automatically picked up by every `BaseRepository` operation in the async call chain —
6
+ no need to thread a `tx` argument through your code.
7
+
8
+ ## Import paths
9
+
10
+ All transaction symbols are re-exported from **`@spfn/core/db`**. There is **no**
11
+ `@spfn/core/db/transaction` package export subpath and **no** `@spfn/core` root export —
12
+ importing from those paths fails to resolve.
13
+
14
+ ```typescript
15
+ import {
16
+ Transactional,
17
+ runInTransaction,
18
+ onBeforeCommit,
19
+ onAfterCommit,
20
+ onAfterRollback,
21
+ getTransaction,
22
+ getTransactionContext,
23
+ runWithTransaction,
24
+ } from '@spfn/core/db';
25
+
26
+ import type {
27
+ TransactionDB,
28
+ TransactionContext,
29
+ TransactionalOptions,
30
+ RunInTransactionOptions,
31
+ BeforeCommitCallback,
32
+ AfterCommitCallback,
33
+ AfterRollbackCallback,
34
+ } from '@spfn/core/db';
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Public API (complete)
40
+
41
+ From `@spfn/core/db`:
42
+
43
+ - `Transactional(options?)` — Hono middleware; wraps a route handler in a transaction.
44
+ - `runInTransaction(callback, options?)` — run a callback in a transaction (scripts/CLI; no Hono).
45
+ - `onBeforeCommit(callback)` — run work inside the root transaction, just before it commits.
46
+ - `onAfterCommit(callback)` — defer a side effect until after the root transaction commits.
47
+ - `onAfterRollback(callback)` — compensate after the root transaction rolled back.
48
+ - `getTransaction()` — current transaction `TransactionDB`, or `null`.
49
+ - `getTransactionContext()` — current `TransactionContext` (`tx`, `txId`, `level`, callbacks), or `null`.
50
+ - `runWithTransaction(tx, txId, callback)` — bind a `tx` into `AsyncLocalStorage` for a callback.
51
+ - Types: `TransactionDB`, `TransactionContext`, `NestedFrameGate`, `TransactionalOptions`, `RunInTransactionOptions`, `BeforeCommitCallback`, `AfterCommitCallback`, `AfterRollbackCallback`.
52
+
53
+ > `getTransactionId()` and `asyncContext` exist in `context.ts` but are **not** exported
54
+ > from the module index — do not import them. Use `getTransactionContext()?.txId` if you
55
+ > need the ID.
56
+
57
+ > **`runWithTransaction` takes three arguments**: `(tx, txId, callback)`. Older docs that
58
+ > call it as `runWithTransaction(tx, callback)` are wrong and will mis-bind the callback as
59
+ > the `txId` string.
60
+
61
+ ---
62
+
63
+ ## How propagation works (read this first)
64
+
65
+ `Transactional` and `runInTransaction` both go through `runInTransaction`, which:
66
+
67
+ 1. Resolves the **write** DB (`getDatabase('write')`).
68
+ 2. Opens a transaction. With no ambient transaction on the call chain that is
69
+ `writeDb.transaction(...)` — a real `BEGIN` on a pooled connection. With one, it is
70
+ `ambientTx.transaction(...)` instead: a **SAVEPOINT on the connection the outer
71
+ transaction already holds**. (`requiresNew: true` forces the first shape — see
72
+ [Nested transactions](#nested-transactions-savepoints).)
73
+ 3. Calls `runWithTransaction(tx, txId, callback)` to store `{ tx, txId, level, beforeCommitCallbacks, afterCommitCallbacks, afterRollbackCallbacks, nestedFrames }` in a global `AsyncLocalStorage`.
74
+
75
+ Inside that callback, any code that calls `getTransaction()` gets the live `tx`. The key
76
+ consumer is **`BaseRepository`**: its `db` and `readDb` getters call `getTransaction()`
77
+ first and use the transaction if present, otherwise fall back to the global write/read
78
+ instance:
79
+
80
+ ```typescript
81
+ // BaseRepository (simplified)
82
+ protected get db() { return getTransaction() ?? getDatabase('write'); }
83
+ protected get readDb() { return getTransaction() ?? getDatabase('read'); }
84
+ ```
85
+
86
+ Consequence: **inside a transaction you write normal repository code** — `userRepo.create(...)`,
87
+ `postRepo.findById(...)` — and they all join the same transaction automatically. You almost
88
+ never call `getTransaction()` yourself.
89
+
90
+ > Inside a transaction, `readDb` also resolves to the transaction connection (not the read
91
+ > replica). Reads within a tx see its own uncommitted writes and run on the primary.
92
+
93
+ ---
94
+
95
+ ## Quick Start
96
+
97
+ ### Route middleware
98
+
99
+ ```typescript
100
+ import { route } from '@spfn/core/route';
101
+ import { Transactional } from '@spfn/core/db';
102
+ import { Type } from '@sinclair/typebox';
103
+
104
+ export const createUser = route.post('/users')
105
+ .input({ body: Type.Object({ email: Type.String(), name: Type.String() }) })
106
+ .use([Transactional()])
107
+ .handler(async (c) =>
108
+ {
109
+ const { body } = await c.data();
110
+
111
+ // Both repo calls automatically join the same transaction.
112
+ const user = await userRepo.create(body);
113
+ await profileRepo.create({ userId: user.id, bio: 'New user' });
114
+
115
+ return user; // success → commit
116
+ // any throw → rollback
117
+ });
118
+ ```
119
+
120
+ ### Script / CLI
121
+
122
+ ```typescript
123
+ import { runInTransaction } from '@spfn/core/db';
124
+
125
+ await runInTransaction(async (tx) =>
126
+ {
127
+ // `tx` is passed in AND available via getTransaction() to nested repo calls.
128
+ const [user] = await tx.insert(users).values({ name: 'John' }).returning();
129
+ await profileRepo.create({ userId: user.id }); // also joins this tx
130
+ return user;
131
+ }, { context: 'script:seed-user', timeout: 60000 });
132
+ ```
133
+
134
+ ---
135
+
136
+ ## `Transactional(options?)` — Hono middleware
137
+
138
+ Wraps the downstream handler chain in a transaction. Commits when the handler resolves,
139
+ rolls back when it throws **or** when Hono stored an error on the context (`c.error`).
140
+
141
+ ```typescript
142
+ .use([Transactional({
143
+ slowThreshold: 2000, // warn if the tx runs longer than 2s (default 1000)
144
+ enableLogging: false, // silence per-tx logs (default true)
145
+ timeout: 60000, // PG statement_timeout in ms (default 30000 / TRANSACTION_TIMEOUT)
146
+ })])
147
+ ```
148
+
149
+ ### Error conversion (rollback path)
150
+
151
+ On rollback the middleware re-throws, but normalizes the error first:
152
+
153
+ 1. Reports the error to the DB reconnect-trigger (no-op for non-connection errors). This sees
154
+ every error, whatever the steps below decide.
155
+ 2. Any `SerializableError` is re-thrown unchanged — the whole framework family, `DatabaseError`
156
+ and `TransactionError` included, plus every application error class that extends it. These
157
+ already carry a `statusCode` and a `toJSON()` envelope, so converting them could only take
158
+ those away. An application error is re-thrown here **even if it carries a `code` field**,
159
+ which a coded refusal (`403` + `code: 'TENANT_SUSPENDED'`) typically does.
160
+ 3. A genuine driver error is converted via `fromPostgresError(...)` → `DuplicateEntryError`,
161
+ `ConstraintViolationError`, `DeadlockError`, `ConnectionError`, `TransactionError`, or
162
+ `QueryError`. "Genuine" means one of two shapes. Either `code` is one of the names postgres.js
163
+ invents for the errors it raises itself (`CONNECTION_CLOSED`, `CONNECTION_ENDED`,
164
+ `CONNECTION_DESTROYED`, `CONNECT_TIMEOUT`, `CONNECTION_CONNECT_TIMEOUT` — the same list the
165
+ reconnect-trigger uses), which carry no severity; or `code` is SQLSTATE-shaped
166
+ (`/^[0-9A-Z]{5}$/`) **and** the error carries a `severity` or `severity_local` field, which is
167
+ what postgres.js copies off the server's `ErrorResponse`. An arbitrary `code` is not enough —
168
+ Stripe (`resource_missing`), jose (`ERR_JOSE_*`), the AWS SDK and Node (`ECONNRESET`) all set one.
169
+ 4. Anything else (e.g. business-logic errors like `InvalidCredentialsError`) is re-thrown as-is.
170
+
171
+ The gate is deliberately narrow: a hand-rolled `{ code: '23505' }` with no severity field is
172
+ **not** converted. That trades a fake-driver edge case for the guarantee that no application
173
+ error is ever flattened into a `QueryError 500` (issue #82). The driver's own connection codes are
174
+ the one exception to the severity requirement — they name their origin unambiguously, so a socket
175
+ that dies mid-transaction still reaches the client as a `QueryError` envelope rather than a bare
176
+ `Error`.
177
+
178
+ Note that Drizzle wraps driver errors in a `DrizzleQueryError` that carries no `code` of its own,
179
+ so a query error raised inside the handler reaches the caller as that wrapper, with the driver's
180
+ `PostgresError` on `.cause`. Step 3 fires for errors that reach the middleware unwrapped. The
181
+ reconnect-trigger in step 1 walks the `cause` chain, so it sees the driver error either way.
182
+
183
+ ### `TransactionalOptions`
184
+
185
+ | Option | Type | Default | Notes |
186
+ |--------|------|---------|-------|
187
+ | `slowThreshold` | `number` | `1000` | ms; logs a `warn` if commit/rollback exceeds it |
188
+ | `enableLogging` | `boolean` | `true` | start/commit/rollback debug logs |
189
+ | `timeout` | `number` | `30000` / `env.TRANSACTION_TIMEOUT` | PostgreSQL `statement_timeout` in ms |
190
+ | `idleTimeout` | `number` | `30000` / `env.TRANSACTION_IDLE_TIMEOUT` | PostgreSQL `idle_in_transaction_session_timeout` in ms |
191
+ | `requiresNew` | `boolean` | `false` | when the middleware runs nested, open an independent transaction instead of a SAVEPOINT — see [Nested transactions](#nested-transactions-savepoints) |
192
+
193
+ ---
194
+
195
+ ## `runInTransaction(callback, options?)`
196
+
197
+ The engine under both the middleware and standalone use. `callback` receives the Drizzle
198
+ `tx`; the same `tx` is also available via `getTransaction()` to anything it calls.
199
+
200
+ ```typescript
201
+ function runInTransaction<T>(
202
+ callback: (tx: TransactionDB) => Promise<T>,
203
+ options?: RunInTransactionOptions,
204
+ ): Promise<T>;
205
+ ```
206
+
207
+ For an injected driver, supply its database type as the second generic. The callback then
208
+ receives that driver's matching Drizzle transaction type:
209
+
210
+ ```typescript
211
+ import type { PgliteDatabase } from 'drizzle-orm/pglite';
212
+
213
+ type AppDatabase = PgliteDatabase<typeof schema>;
214
+
215
+ await runInTransaction<void, AppDatabase>(async (tx) =>
216
+ {
217
+ await tx.insert(schema.users).values({ id: 'user-1' });
218
+ });
219
+ ```
220
+
221
+ ### `RunInTransactionOptions`
222
+
223
+ | Option | Type | Default | Notes |
224
+ |--------|------|---------|-------|
225
+ | `slowThreshold` | `number` | `1000` | ms; must be a non-negative integer or it throws `TransactionError` |
226
+ | `enableLogging` | `boolean` | `true` | |
227
+ | `timeout` | `number` | `env.TRANSACTION_TIMEOUT` (30000) | see timeout semantics below |
228
+ | `idleTimeout` | `number` | `env.TRANSACTION_IDLE_TIMEOUT` (30000) | see timeout semantics below |
229
+ | `context` | `string` | `'transaction'` | label in logs (the middleware passes `"METHOD /path"`) |
230
+ | `requiresNew` | `boolean` | `false` | run in an independent transaction instead of joining an ambient one — see [Nested transactions](#nested-transactions-savepoints) |
231
+
232
+ ### Timeout semantics
233
+
234
+ `timeout` becomes a `SET LOCAL statement_timeout = <ms>` issued at the start of the **root**
235
+ transaction (via `sql.raw`, since `SET` can't be parameterized). Resolution order is
236
+ `options.timeout ?? env.TRANSACTION_TIMEOUT`:
237
+
238
+ - `timeout: 0` — disables the timeout (unlimited).
239
+ - `null` / `undefined` — falls back to `env.TRANSACTION_TIMEOUT` (default 30000).
240
+ - `N` — must be an integer in `0 … 2147483647` (PG max int4). Out-of-range / non-integer values throw `TransactionError` (status 400) before any DB access.
241
+
242
+ Timeout is applied **only to root transactions**. A nested call takes a SAVEPOINT on the
243
+ outer transaction's connection, where that transaction's `SET LOCAL statement_timeout` is
244
+ already in force — so the nested call **inherits** it, and its own `timeout` is ignored (a
245
+ `warn` is logged only when the caller passed one explicitly; inheriting the root's is the
246
+ normal case). Issuing `SET LOCAL` there would re-scope the entire outer transaction, not
247
+ just the savepoint. A `requiresNew: true` call is a root on its own connection and gets its
248
+ own timeouts.
249
+
250
+ `idleTimeout` is the companion knob: `SET LOCAL idle_in_transaction_session_timeout = <ms>`,
251
+ also root-only, resolved as `options.idleTimeout ?? env.TRANSACTION_IDLE_TIMEOUT` (default
252
+ 30000). Where `statement_timeout` bounds a single query's run time, this bounds how long the
253
+ transaction may sit **idle** (no query running) — e.g. while the handler awaits external I/O.
254
+ On expiry Postgres terminates the session and rolls back, **reclaiming the pooled connection**
255
+ instead of letting one stuck request hold it (and its row locks) indefinitely. `0` disables it.
256
+ This is a backstop, not a license — see the anti-pattern below.
257
+
258
+ ### Validation / errors
259
+
260
+ `runInTransaction` fails fast with a `TransactionError` (before opening a transaction) when:
261
+ `callback` is not a function; `slowThreshold` is negative/non-integer; `timeout` is
262
+ non-integer, negative, or above the max; or the write database is not initialized
263
+ (status 500). Errors thrown by `callback` itself propagate unchanged after rollback.
264
+
265
+ ---
266
+
267
+ ## Nested transactions (SAVEPOINTs)
268
+
269
+ Nesting **is** supported. When `runInTransaction` / `Transactional` runs while an
270
+ `AsyncLocalStorage` context already exists, the runner opens the inner transaction off the
271
+ ambient one (`ambientTx.transaction(...)`), so Drizzle issues a PostgreSQL `SAVEPOINT` on the
272
+ connection the outer transaction already holds, and `runWithTransaction` increments `level`
273
+ (root = 1).
274
+
275
+ ```typescript
276
+ await runInTransaction(async () => // level 1 (root, real BEGIN)
277
+ {
278
+ await userRepo.create(...);
279
+
280
+ try
281
+ {
282
+ await runInTransaction(async () => // level 2 (SAVEPOINT, same connection)
283
+ {
284
+ await postRepo.create(...);
285
+ throw new Error('inner'); // ROLLBACK TO the SAVEPOINT
286
+ });
287
+ }
288
+ catch
289
+ {
290
+ // The catch is what makes this "the post was optional". Without it the
291
+ // error keeps propagating out of the root callback and rolls the ROOT
292
+ // back too — the savepoint limits what the ERROR undoes, never where it
293
+ // travels.
294
+ }
295
+
296
+ await auditRepo.record(...); // the root is healthy; keep going
297
+ }); // root commits the user + the audit row
298
+ ```
299
+
300
+ Behavior to know:
301
+
302
+ - **One connection.** The nested call runs on the outer transaction's connection —
303
+ `pg_backend_pid()` is equal at every level. It therefore sees the outer transaction's
304
+ **uncommitted** writes, takes no second checkout from the pool, and cannot deadlock
305
+ against a row the outer transaction locked.
306
+ - **One commit.** Nested writes are durable only when the **root** commits. There is no
307
+ intermediate commit at a savepoint boundary.
308
+ - The inner level shares **all three of the root's** hook queues, so a hook registered in
309
+ a nested call fires at the **root's** boundary, not the savepoint's. See
310
+ [Transaction hooks](#transaction-hooks).
311
+ - **One at a time.** Nested calls made off the same transaction are serialized: a frame
312
+ opens only after the previous frame on that connection has closed. See
313
+ [Concurrent nested calls](#concurrent-nested-calls-are-serialized).
314
+ - `timeout` passed to a nested call is ignored; the root's timeout is already in force on
315
+ the shared connection and governs. A `warn` is logged only if you passed a `timeout`
316
+ explicitly — inheriting the root's is the normal case and says nothing worth logging.
317
+ - An inner rollback unwinds to its savepoint; the outer can catch and continue issuing
318
+ statements. This holds for a failed *statement* too: inside a savepoint, PostgreSQL's
319
+ aborted-transaction state (`25P02`) unwinds to the savepoint rather than poisoning the
320
+ whole transaction.
321
+
322
+ > **⚠️ BREAKING (from the release that introduced this)**
323
+ >
324
+ > A nested call **used to open an independent transaction on a second pooled connection**,
325
+ > despite these docs. It committed on its own, could not see the outer transaction's
326
+ > uncommitted writes, and self-deadlocked on rows the outer transaction had locked. It is now
327
+ > a SAVEPOINT, as documented.
328
+ >
329
+ > **Code that relied on a nested call committing independently must now pass
330
+ > `requiresNew: true`.** The usual case is an audit or failed-attempt record that must
331
+ > survive the outer rollback. Everything else — the overwhelming majority of nesting, where
332
+ > the inner work is simply part of the outer unit — needs no change and silently gets
333
+ > correct behavior.
334
+ >
335
+ > **Second dimension: nested calls made off one transaction no longer run concurrently.**
336
+ > They used to hold a connection each, so `Promise.all` over them really did overlap. They
337
+ > now share the outer transaction's connection, where overlapping savepoints corrupt each
338
+ > other, so the runner serializes them — `Promise.all([nestedA(), nestedB()])` still
339
+ > resolves with both results, just one after the other, and a call chain that fanned out
340
+ > N nested calls for latency loses that parallelism. Give a branch `requiresNew: true` to
341
+ > get its own connection back, and read
342
+ > [Concurrent nested calls](#concurrent-nested-calls-are-serialized) for the one shape that
343
+ > deadlocks.
344
+
345
+ ### `requiresNew: true` — opt back out into an independent transaction
346
+
347
+ Available on both `runInTransaction` and `Transactional()`. It opens a real `BEGIN` on a
348
+ second pooled connection, ignoring the ambient transaction entirely:
349
+
350
+ ```typescript
351
+ await runInTransaction(async () =>
352
+ {
353
+ await orderRepo.create(order);
354
+
355
+ // Commits on its own — lands even though the outer transaction rolls back below.
356
+ await runInTransaction(
357
+ () => auditRepo.record('order.attempted', order.id),
358
+ { requiresNew: true },
359
+ );
360
+
361
+ await inventoryRepo.reserve(order.items); // throws → outer rolls back
362
+ });
363
+ ```
364
+
365
+ Being a root transaction, it gets its own `statement_timeout`, its own
366
+ `idle_in_transaction_session_timeout`, and its **own** hook queues: `onBeforeCommit` /
367
+ `onAfterCommit` / `onAfterRollback` registered inside it fire on **its** outcome, not the
368
+ outer transaction's. `getTransactionContext()?.level` reads `1` inside it, however deeply it
369
+ sits lexically — `level` counts savepoint depth, and this call is a root.
370
+
371
+ Two costs, both consequences of the second connection — this is where the pool-starvation
372
+ caveat that used to apply to *every* nested call now lives:
373
+
374
+ - It holds a **second connection** for its whole duration, so the call chain counts twice
375
+ against the pool. Keep it short, and don't fan it out.
376
+ - It cannot see the outer transaction's uncommitted writes, and it **blocks** on any row the
377
+ outer transaction has locked. Since the outer transaction is waiting for this call to
378
+ return, that block is a **self-deadlock** that only `statement_timeout` breaks. Never touch
379
+ rows the outer transaction wrote.
380
+
381
+ ### Concurrent nested calls are serialized
382
+
383
+ Two nested calls under `Promise.all` would be two savepoints on **one** connection, and
384
+ `ROLLBACK TO` unwinds the connection, not a branch of it — so a failing sibling would
385
+ discard everything written since its savepoint, including the other sibling's rows, while
386
+ that sibling reported success. The runner therefore **queues nested frames per transaction**:
387
+ a frame opens only after the frame before it on that connection has closed.
388
+
389
+ ```typescript
390
+ // Still correct — and now genuinely atomic per branch. Just not parallel:
391
+ // stepB's SAVEPOINT is taken after stepA's frame has closed.
392
+ await Promise.all([
393
+ runInTransaction(() => stepA()),
394
+ runInTransaction(() => stepB()),
395
+ ]);
396
+ ```
397
+
398
+ The queue belongs to the transaction the frames are opened off, so it never blocks depth
399
+ (a nested call inside `stepA` waits on `stepA`'s own queue, which is empty) and never
400
+ touches `requiresNew`, which runs on a connection of its own.
401
+
402
+ - **You lose the parallelism, not the results.** Both branches still run and both results
403
+ still come back. If the fan-out existed for latency, give each branch `requiresNew: true`
404
+ — separate connections, genuinely concurrent, and each commits on its own instead of with
405
+ the root.
406
+ - **⚠️ One shape deadlocks: a nested call whose callback awaits a sibling that was started
407
+ after it.** The waiting frame holds the queue, so the sibling behind it can never open,
408
+ and nothing breaks the cycle — `statement_timeout` does not fire, because no statement is
409
+ running. This is misuse rather than a guarded case: the runner cannot see which promise a
410
+ callback is blocked on, and a wait limit would have to guess how long a legitimate sibling
411
+ may run — aborting slow-but-correct transactions. The first time frames contend, one `warn`
412
+ ("Concurrent nested transactions are serialized") is logged per process, so the hazard is
413
+ on the record before it can hang.
414
+
415
+ ```typescript
416
+ // ❌ Deadlock: A entered the queue first, so it holds it — and it is waiting for
417
+ // B, which is queued behind A and can never open. Frames open in the order the
418
+ // calls were made, so this needs the awaited sibling to be created AFTER the
419
+ // waiting one; the reverse (create B first, await it from A) is fine, because
420
+ // B has already run by the time A opens.
421
+ const a = runInTransaction(async () => { await b; });
422
+ const b = runInTransaction(() => stepB());
423
+
424
+ await Promise.all([a, b]);
425
+ ```
426
+
427
+ Statements issued **directly** on the outer transaction have the same hazard and no queue
428
+ to protect them: `Promise.all([tx.insert(...), runInTransaction(() => stepB())])` writes the
429
+ outer row after `stepB`'s savepoint was taken, so `stepB` failing discards it. Don't overlap
430
+ outer-transaction work with an open nested frame either.
431
+
432
+ ### Operational caveats of the driver's savepoints
433
+
434
+ Verified against the pinned driver (`drizzle-orm` 1.0.0-rc.4 over `postgres` 3.4.7) by
435
+ logging the SQL it emits — worth knowing before nesting deeply or in a loop:
436
+
437
+ - **`RELEASE SAVEPOINT` is never issued.** The driver emits `savepoint "sN"` on entry and
438
+ `rollback to "sN"` on failure, and nothing at all on success — the savepoint simply stays
439
+ defined until the root transaction ends. Names are unique per transaction (`s0`, `s1`, …),
440
+ so nothing shadows anything; they just accumulate.
441
+ - **A write inside a nested frame costs a subtransaction, and the backend caches 64.** Each
442
+ savepoint that writes assigns a subtransaction id. PostgreSQL keeps 64 of them per backend
443
+ (`PGPROC_MAX_CACHED_SUBXIDS`); beyond that the backend is marked *suboverflowed*, and other
444
+ sessions' visibility checks stop being answerable from shared memory and go to `pg_subtrans`
445
+ on disk instead — a cliff that slows the **whole cluster's** snapshot checks, not just this
446
+ transaction. Measured on PostgreSQL 16.15: `subxact_count` climbs to 64 and
447
+ `subxact_overflowed` flips to `true` on the 65th write-savepoint.
448
+ - **Practical rule:** don't put a nested `runInTransaction` inside a loop over more than a
449
+ few dozen rows. Do the batch as one statement, or as plain repository calls in the outer
450
+ transaction — nesting per row buys nothing unless you need per-row rollback.
451
+
452
+ ---
453
+
454
+ ## Transaction hooks
455
+
456
+ Three hooks attach work to the **root** transaction's lifecycle. All three take
457
+ `() => void | Promise<void>`, are registered the same way from anywhere inside the async
458
+ call chain, and queue on the root context (`BeforeCommitCallback`, `AfterCommitCallback`,
459
+ `AfterRollbackCallback` are the exported type aliases).
460
+
461
+ | Hook | Runs | Still inside the tx? | If the callback throws |
462
+ |------|------|----------------------|------------------------|
463
+ | `onBeforeCommit(cb)` | after the root callback resolves, **before** `COMMIT` | **yes** — may run statements | aborts: later callbacks skipped, transaction rolls back, error propagates, `afterRollback` fires |
464
+ | `onAfterCommit(cb)` | after the root transaction committed | no | logged, never thrown |
465
+ | `onAfterRollback(cb)` | after the root transaction rolled back | no | logged, never thrown; the **original** error keeps propagating unchanged |
466
+
467
+ Registration context:
468
+
469
+ | Context | `onBeforeCommit` | `onAfterCommit` | `onAfterRollback` |
470
+ |---------|------------------|-----------------|-------------------|
471
+ | Inside root transaction | Queued; runs before the root's commit | Queued; runs after the root commits | Queued; runs if the root rolls back |
472
+ | Inside a nested transaction | Queued on the **root** queue | Queued on the **root** queue | Queued on the **root** queue |
473
+ | Outside any transaction | Runs immediately on a microtask **+ `warn` log** — nothing left to commit, so a throw aborts nothing | Runs immediately on a microtask (already "committed") | **No-op + `warn` log** — there is no rollback to wait for |
474
+
475
+ ### `onBeforeCommit(callback)`
476
+
477
+ The last moment the transaction is still open. Use it for cross-cutting work that must be
478
+ part of the same commit — a final invariant check, an audit row, a denormalized counter —
479
+ without threading it through every call site.
480
+
481
+ ```typescript
482
+ import { runInTransaction, onBeforeCommit } from '@spfn/core/db';
483
+
484
+ async function transfer(fromId: string, toId: string, amount: number)
485
+ {
486
+ // The transaction is what gives the check teeth — see the warning below.
487
+ await runInTransaction(async () =>
488
+ {
489
+ await accountRepo.debit(fromId, amount);
490
+ await accountRepo.credit(toId, amount);
491
+
492
+ // Runs inside the transaction: a throw rolls the whole transfer back.
493
+ onBeforeCommit(() => assertNoNegativeBalance(fromId));
494
+ });
495
+ }
496
+ ```
497
+
498
+ - Runs **inside** the transaction context: `getTransaction()` returns the live `tx`, and
499
+ repositories called from a callback join the same transaction, so their writes are part
500
+ of the same commit.
501
+ - Runs in registration order, one at a time, and only after the user callback resolved
502
+ successfully — the runner never starts this pass on a transaction that already failed.
503
+ (If your own code *swallowed* a statement error, PostgreSQL has aborted the transaction
504
+ and every statement here fails with `25P02` — but so would the `COMMIT`; the hook only
505
+ surfaces that earlier.)
506
+ - A throw is not caught: later callbacks are skipped, the transaction rolls back, the error
507
+ propagates to the caller, and `afterRollback` callbacks fire.
508
+ - **Registered outside a transaction, the hook keeps none of that.** The callback runs
509
+ immediately on a microtask and a `warn` is logged: there is nothing to abort, so a
510
+ throwing invariant check is swallowed into a log line while the write it meant to prevent
511
+ has already committed. Always register it from inside `runInTransaction`/`Transactional`.
512
+ - The queue is **snapshot before the pass**: a callback registered *by* a beforeCommit
513
+ callback does not run for this commit. Growing the queue mid-iteration would loop forever
514
+ inside the open transaction (with `statement_timeout` powerless — no statement is
515
+ running), so the snapshot is deliberate, not incidental.
516
+ - Calling `onAfterCommit` from a beforeCommit callback works — the queue is read after the
517
+ beforeCommit pass.
518
+
519
+ ### `onAfterCommit(callback)`
520
+
521
+ Defer a side effect (notifications, jobs, analytics, cache busting) until **after** the data
522
+ is durably committed.
523
+
524
+ ```typescript
525
+ import { onAfterCommit } from '@spfn/core/db';
526
+
527
+ async function submit(spaceId: string, chatId: string)
528
+ {
529
+ const publication = await publicationRepo.create({ spaceId, chatId });
530
+ await requestRepo.updateStatusAtomically(requestId, 'submitted');
531
+
532
+ onAfterCommit(() => generateArticle(spaceId, chatId, publication.id));
533
+
534
+ return publication;
535
+ }
536
+ ```
537
+
538
+ - Callbacks run **outside** the transaction context — a `getTransaction()` inside one
539
+ returns `null`, so DB work uses a fresh connection (a new transaction, not this one).
540
+ - Fire-and-forget: each callback runs via `Promise.resolve().then(cb).catch(log)`. Errors
541
+ are logged, never thrown, and never affect the (already committed) transaction.
542
+ - Execution is FIFO in registration order; multiple callbacks per transaction are fine.
543
+ - If the transaction **rolls back**, queued callbacks never run.
544
+
545
+ ### `onAfterRollback(callback)`
546
+
547
+ Compensate for non-transactional work once the transaction is known to be gone — delete an
548
+ uploaded object, release a reserved external id, mark a cached intent as failed.
549
+
550
+ ```typescript
551
+ import { runInTransaction, onAfterRollback } from '@spfn/core/db';
552
+
553
+ async function importAvatar(userId: string, file: Blob)
554
+ {
555
+ // Upload first: external I/O never belongs inside the transaction.
556
+ const key = await objectStore.put(file);
557
+
558
+ await runInTransaction(async () =>
559
+ {
560
+ await userRepo.updateAvatar(userId, key);
561
+
562
+ // The upload cannot roll back on its own — undo it if the write never lands.
563
+ onAfterRollback(() => objectStore.delete(key));
564
+ });
565
+ }
566
+ ```
567
+
568
+ - Fires on **any** root rollback: a thrown handler error, a thrown `onBeforeCommit`
569
+ callback, a statement timeout, a constraint violation.
570
+ - Callbacks run **outside** the transaction context (`getTransaction()` is `null`), after
571
+ the driver rolled back — DB work in them uses a fresh connection, like `onAfterCommit`.
572
+ - They are **awaited**, in registration order, before the causing error leaves
573
+ `runInTransaction` / the middleware. A callback that hangs delays that error, so keep
574
+ them short.
575
+ - Errors are logged and swallowed: a failing callback never replaces the error that caused
576
+ the rollback, never stops the remaining callbacks, and neither does a failure of the log
577
+ call itself.
578
+ - The trigger is "the transaction did not report success", which is *almost* always a
579
+ rollback. A connection lost at exactly the `COMMIT` leaves the outcome genuinely unknown
580
+ to the client, and these callbacks fire — so a compensation should be idempotent and
581
+ safe to run against data that did, in the end, land.
582
+
583
+ ### Hooks are scoped to the root transaction
584
+
585
+ Every queue lives on the root context and nested contexts share it, so the **root's** fate
586
+ decides everything:
587
+
588
+ - A nested transaction that rolls back while the **root commits** fires **no**
589
+ `afterRollback` callbacks — not the ones registered nested, not the ones registered at the
590
+ root. The hooks answer "did the root transaction survive?", and it did. Compensating for a
591
+ nested failure the outer code deliberately caught and recovered from is that code's job.
592
+ - Conversely, hooks registered in a nested call fire exactly once, at the root's boundary —
593
+ never once per nesting level.
594
+ - **Registering compensations from a nested call is correct for the root's fate — and only
595
+ for it.** A nested call is a SAVEPOINT: its writes are durable only if the root commits,
596
+ so when the nested call *succeeded* and the root later rolls back, an `onAfterRollback`
597
+ registered inside it fires exactly when the work it compensates for disappears, and stays
598
+ silent when the root commits that work.
599
+ It is **not** a general "fires when my writes disappear" hook: in the caught-rollback case
600
+ above, the nested writes are gone and no hook fires at all. Code that catches a nested
601
+ failure and carries on must compensate **in the catch block**, at the moment it decides to
602
+ recover; deferring that to `onAfterRollback` compensates for nothing.
603
+ - A `requiresNew: true` call is the exception, because it is a root: hooks registered inside
604
+ it belong to it and fire on its own commit or rollback.
605
+
606
+ ### Why there is no `onBeforeRollback`
607
+
608
+ The moment does not exist. A rollback is triggered by a statement error, which puts the
609
+ PostgreSQL session into the aborted-transaction state (`25P02`): every subsequent statement
610
+ except `ROLLBACK` fails with *"current transaction is aborted, commands ignored until end of
611
+ transaction block"*. A hook there could not read, write, or log to the database — the only
612
+ thing it could do is non-DB work, which belongs in `onAfterRollback`, where it runs on a
613
+ healthy connection.
614
+
615
+ ---
616
+
617
+ ## Low-level: `getTransaction` / `getTransactionContext` / `runWithTransaction`
618
+
619
+ You normally don't touch these — `BaseRepository` already resolves the transaction for you.
620
+ Reach for them only when writing a custom wrapper, or for a repository-less helper that must
621
+ manually honor an ambient transaction.
622
+
623
+ ```typescript
624
+ import { getTransaction, getTransactionContext, runWithTransaction } from '@spfn/core/db';
625
+
626
+ getTransaction(); // TransactionDB | null
627
+ getTransactionContext(); // { tx, txId, level, ...hook queues, nestedFrames } | null
628
+
629
+ // Bind an existing Drizzle tx so nested code sees it via getTransaction().
630
+ // NOTE the 3-arg signature: (tx, txId, callback)
631
+ await db.transaction(async (tx) =>
632
+ {
633
+ return await runWithTransaction(tx, `tx_${crypto.randomUUID()}`, async () =>
634
+ {
635
+ // getTransaction() === tx here and in everything this calls
636
+ return doWork();
637
+ });
638
+ });
639
+ ```
640
+
641
+ > Prefer `runInTransaction` over hand-rolling `db.transaction()` + `runWithTransaction`:
642
+ > the runner adds the `txId`, timeout enforcement, slow-tx logging, and the hook-queue
643
+ > plumbing that raw `runWithTransaction` does not. `runWithTransaction` creates the queues,
644
+ > but only the runner ever fires them — hooks registered under a hand-rolled
645
+ > `runWithTransaction` never run.
646
+
647
+ ---
648
+
649
+ ## Pitfalls & anti-patterns
650
+
651
+ - **Never do external I/O inside a transaction.** A transaction holds a pooled connection
652
+ (and any row locks taken) from `BEGIN` to `COMMIT`. If the handler awaits an external API,
653
+ queue, or other non-DB work while the transaction is open, that connection sits idle but
654
+ reserved — under load, in-flight requests cap out at the pool size and everything else
655
+ queues. Route-level `Transactional()` wraps the **whole handler**, so it's especially easy
656
+ to fall into; prefer scoping the transaction to the DB statements (call `runInTransaction`
657
+ inside a service around just the writes). The `idle_in_transaction_session_timeout` backstop
658
+ reaps the worst case, and a "Slow transaction" `warn` flags offenders — but the fix is to
659
+ move the I/O out. If you have a write → external-call → write flow where the external call
660
+ has a side effect (charge, send), don't span it with a transaction at all — commit intent,
661
+ call outside the transaction, then commit the result (outbox / saga).
662
+ - **Import from `@spfn/core/db`, not `@spfn/core/db/transaction` or `@spfn/core`.** Neither
663
+ of the latter is a real package export — they don't resolve.
664
+ - **`runWithTransaction` is `(tx, txId, callback)`.** Calling it with two args silently
665
+ binds your callback into the `txId` slot. If you only have repositories, you don't need
666
+ this function at all — start a transaction with `runInTransaction`/`Transactional`.
667
+ - **Don't pass `tx` around manually when using repositories.** Repositories read the
668
+ ambient transaction via `getTransaction()`. Threading a `tx` parameter is redundant and
669
+ invites bugs where one path forgets it.
670
+ - **Don't nest a raw `db.transaction()` without `runWithTransaction`.** A raw
671
+ `db.transaction()` opens a Drizzle SAVEPOINT but does **not** update `AsyncLocalStorage`,
672
+ so repositories inside it still use the *outer* tx, not the savepoint — your "inner"
673
+ rollback won't isolate as expected. Use `runInTransaction` (which wires both) instead.
674
+ - **`timeout` on a nested call is ignored.** Set the timeout on the outermost
675
+ transaction/middleware; an explicitly passed nested value is dropped with a warning, and
676
+ the root's timeout governs the savepoint anyway.
677
+ - **Concurrent nested calls are serialized, and one shape of them deadlocks.** Sibling
678
+ savepoints share the outer transaction's connection, so the runner queues them; `Promise.all`
679
+ over nested calls is correct but no longer parallel. A nested call whose callback *awaits a
680
+ sibling started after it* hangs forever — see
681
+ [Concurrent nested calls](#concurrent-nested-calls-are-serialized).
682
+ - **Don't open a nested transaction per row in a loop.** Each writing savepoint burns one of
683
+ the backend's 64 cached subtransaction ids and is never released before the root ends; past
684
+ that the backend goes suboverflowed and every other session's visibility checks get slower.
685
+ - **`requiresNew: true` costs a second connection and can self-deadlock.** Use it only when
686
+ the inner work must survive an outer rollback (an audit row, a failed-attempt record), keep
687
+ it short, and never let it touch rows the outer transaction wrote.
688
+ - **Side effects belong in `onAfterCommit`, not the handler body.** Calling a notifier /
689
+ job / external API directly in the handler runs it *before* commit and holds the
690
+ transaction (and its pooled connection) open. If the tx later rolls back, you've already
691
+ fired the side effect.
692
+ - **`onAfterCommit` DB work runs in a new connection, not this transaction.** It executes
693
+ after the context is gone, so `getTransaction()` is `null` inside it — its writes are a
694
+ separate transaction and won't roll back with the original.
695
+ - **No external I/O in `onBeforeCommit`.** It runs *inside* the transaction, so an API call
696
+ there holds the connection open exactly like one in the handler body. Keep it to DB work
697
+ and cheap invariant checks; anything else belongs in `onAfterCommit`.
698
+ - **`onAfterRollback` is not a "retry the write" hook.** It runs after the transaction is
699
+ gone, on a fresh connection. Use it to undo work that was never transactional in the first
700
+ place (an upload, an external reservation), not to re-attempt the failed statements.
701
+ - **Keep transactions short.** No network I/O / file uploads inside the tx — do that work
702
+ first, then run only the DB writes in the transaction (see example below). Long
703
+ transactions hold connections and can exhaust the pool.
704
+ - **`getTransaction()` returns `null` outside a transaction.** It is not a "get me a db"
705
+ helper. For plain DB access use a `BaseRepository` or `getDatabase()`.
706
+
707
+ ```typescript
708
+ // ❌ Long transaction: external work holds the tx + connection open
709
+ .use([Transactional()])
710
+ .handler(async (c) =>
711
+ {
712
+ const data = await fetch('https://api.example.com').then(r => r.json()); // I/O in tx
713
+ await userRepo.create(data);
714
+ await uploadFile(file); // I/O in tx
715
+ return { ok: true };
716
+ });
717
+
718
+ // ✅ External work first, DB writes only inside the transaction
719
+ .handler(async (c) =>
720
+ {
721
+ const data = await fetch('https://api.example.com').then(r => r.json());
722
+ await uploadFile(file);
723
+
724
+ return runInTransaction(async () =>
725
+ {
726
+ const user = await userRepo.create(data);
727
+ onAfterCommit(() => notify(user.id)); // side effect after commit
728
+ return user;
729
+ });
730
+ });
731
+ ```
732
+
733
+ ---
734
+
735
+ ## Complete example
736
+
737
+ ```typescript
738
+ // routes/publications.ts
739
+ import { route } from '@spfn/core/route';
740
+ import { Transactional, onAfterCommit } from '@spfn/core/db';
741
+ import { Type } from '@sinclair/typebox';
742
+
743
+ export const submit = route.post('/publications')
744
+ .input({ body: Type.Object({ spaceId: Type.String(), chatId: Type.String() }) })
745
+ .use([Transactional({ timeout: 15000, slowThreshold: 2000 })])
746
+ .handler(async (c) =>
747
+ {
748
+ const { body } = await c.data();
749
+
750
+ // Repositories auto-join the middleware's transaction.
751
+ const publication = await publicationRepo.create(body);
752
+ await requestRepo.updateStatusAtomically(body.chatId, 'submitted');
753
+
754
+ // Fires only after the transaction commits; runs on a fresh connection.
755
+ onAfterCommit(() => generateArticle(body.spaceId, body.chatId, publication.id));
756
+
757
+ return publication; // commit; a throw here → rollback (PG errors normalized)
758
+ });
759
+ ```
760
+
761
+ ```typescript
762
+ // scripts/backfill.ts — same propagation, no Hono
763
+ import { runInTransaction } from '@spfn/core/db';
764
+
765
+ await runInTransaction(async (tx) =>
766
+ {
767
+ const rows = await tx.select().from(legacy);
768
+ for (const row of rows)
769
+ {
770
+ await userRepo.create(mapLegacy(row)); // joins this tx via getTransaction()
771
+ }
772
+ }, { context: 'script:backfill', timeout: 0 /* disable timeout for a long backfill */ });
773
+ ```
774
+
775
+ ---
776
+
777
+ ## Types reference
778
+
779
+ ```typescript
780
+ type TransactionDB<TDatabase = DefaultDatabase> = DatabaseTransaction<TDatabase>;
781
+
782
+ type BeforeCommitCallback = () => void | Promise<void>;
783
+ type AfterCommitCallback = () => void | Promise<void>;
784
+ type AfterRollbackCallback = () => void | Promise<void>;
785
+
786
+ type TransactionContext<TDatabase = DrizzleDatabase> = {
787
+ tx: TransactionDB<TDatabase>; // live Drizzle transaction
788
+ txId: string; // "tx_<uuid>" — tracing id
789
+ level: number; // nesting depth, root = 1
790
+ beforeCommitCallbacks: BeforeCommitCallback[]; // shared with the root
791
+ afterCommitCallbacks: AfterCommitCallback[]; // shared with the root
792
+ afterRollbackCallbacks: AfterRollbackCallback[]; // shared with the root
793
+ nestedFrames: NestedFrameGate; // own — queues THIS context's frames
794
+ };
795
+
796
+ type NestedFrameGate = {
797
+ run<T>(frame: () => Promise<T>): Promise<T>; // internal; the runner calls it
798
+ };
799
+
800
+ interface TransactionalOptions {
801
+ slowThreshold?: number; // default 1000 (ms)
802
+ enableLogging?: boolean; // default true
803
+ timeout?: number; // default 30000 / env.TRANSACTION_TIMEOUT (ms)
804
+ idleTimeout?: number; // default 30000 / env.TRANSACTION_IDLE_TIMEOUT (ms)
805
+ requiresNew?: boolean; // default false — independent tx instead of a SAVEPOINT
806
+ }
807
+
808
+ interface RunInTransactionOptions {
809
+ slowThreshold?: number; // default 1000 (ms)
810
+ enableLogging?: boolean; // default true
811
+ timeout?: number; // default env.TRANSACTION_TIMEOUT (30000 ms)
812
+ idleTimeout?: number; // default env.TRANSACTION_IDLE_TIMEOUT (30000 ms)
813
+ context?: string; // default 'transaction'
814
+ requiresNew?: boolean; // default false — independent tx instead of a SAVEPOINT
815
+ }
816
+ ```
817
+
818
+ ## Related
819
+
820
+ - [@spfn/core/db](../README.md) — database connection, `BaseRepository`, `getDatabase`
821
+ - [@spfn/core/config](../../config/README.md) — `TRANSACTION_TIMEOUT` and other settings
822
+ - [@spfn/core/errors](../../errors/README.md) — `TransactionError`, `DatabaseError`, `fromPostgresError` results