@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31

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.
Files changed (104) hide show
  1. package/README.md +304 -12
  2. package/dist/app-config/index.d.ts +39 -1
  3. package/dist/app-config/index.js.map +1 -1
  4. package/dist/authz/index.js +47 -391
  5. package/dist/authz/index.js.map +1 -1
  6. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  7. package/dist/codegen/index.d.ts +157 -16
  8. package/dist/codegen/index.js +772 -166
  9. package/dist/codegen/index.js.map +1 -1
  10. package/dist/config/index.js +4 -4
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/contract/index.d.ts +39 -16
  13. package/dist/contract/index.js +147 -23
  14. package/dist/contract/index.js.map +1 -1
  15. package/dist/db/index.d.ts +230 -35
  16. package/dist/db/index.js +316 -160
  17. package/dist/db/index.js.map +1 -1
  18. package/dist/env/index.d.ts +6 -442
  19. package/dist/env/index.js +13 -16
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/loader.js +24 -1
  22. package/dist/env/loader.js.map +1 -1
  23. package/dist/error-CIZlYJlL.d.ts +157 -0
  24. package/dist/errors/index.d.ts +18 -1
  25. package/dist/errors/index.js +47 -391
  26. package/dist/errors/index.js.map +1 -1
  27. package/dist/event/index.d.ts +5 -5
  28. package/dist/event/index.js +202 -3
  29. package/dist/event/index.js.map +1 -1
  30. package/dist/event/sse/client.d.ts +18 -4
  31. package/dist/event/sse/client.js +94 -18
  32. package/dist/event/sse/client.js.map +1 -1
  33. package/dist/event/sse/index.d.ts +18 -5
  34. package/dist/event/sse/index.js +452 -114
  35. package/dist/event/sse/index.js.map +1 -1
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.js +52 -397
  44. package/dist/middleware/index.js.map +1 -1
  45. package/dist/nextjs/index.d.ts +75 -14
  46. package/dist/nextjs/index.js +200 -84
  47. package/dist/nextjs/index.js.map +1 -1
  48. package/dist/nextjs/server.d.ts +46 -8
  49. package/dist/nextjs/server.js +15 -1
  50. package/dist/nextjs/server.js.map +1 -1
  51. package/dist/ops/index.d.ts +61 -42
  52. package/dist/ops/index.js +648 -97
  53. package/dist/ops/index.js.map +1 -1
  54. package/dist/registry-C2KQCh0m.d.ts +473 -0
  55. package/dist/route/index.d.ts +5 -2
  56. package/dist/route/index.js +107 -28
  57. package/dist/route/index.js.map +1 -1
  58. package/dist/route/types.d.ts +7 -1
  59. package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
  60. package/dist/router-C57y-vz1.d.ts +184 -0
  61. package/dist/router-CX5uVjmU.d.ts +406 -0
  62. package/dist/server/index.d.ts +130 -7
  63. package/dist/server/index.js +1174 -164
  64. package/dist/server/index.js.map +1 -1
  65. package/dist/state/client.d.ts +206 -0
  66. package/dist/state/client.js +678 -0
  67. package/dist/state/client.js.map +1 -0
  68. package/dist/state/index.d.ts +443 -0
  69. package/dist/state/index.js +644 -0
  70. package/dist/state/index.js.map +1 -0
  71. package/dist/state/react.d.ts +49 -0
  72. package/dist/state/react.js +50 -0
  73. package/dist/state/react.js.map +1 -0
  74. package/dist/surface/index.d.ts +115 -0
  75. package/dist/surface/index.js +1013 -0
  76. package/dist/surface/index.js.map +1 -0
  77. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  78. package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  79. package/dist/types-CKH0D3Dy.d.ts +441 -0
  80. package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
  81. package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
  82. package/docs/file-upload.md +195 -333
  83. package/package.json +41 -11
  84. package/src/cache/README.md +330 -0
  85. package/src/codegen/README.md +721 -0
  86. package/src/config/README.md +333 -0
  87. package/src/contract/README.md +442 -0
  88. package/src/db/README.md +589 -0
  89. package/src/db/manager/README.md +500 -0
  90. package/src/db/schema/README.md +354 -0
  91. package/src/db/transaction/README.md +845 -0
  92. package/src/env/README.md +787 -0
  93. package/src/errors/README.md +445 -0
  94. package/src/event/README.md +994 -0
  95. package/src/job/README.md +613 -0
  96. package/src/logger/README.md +321 -0
  97. package/src/middleware/README.md +634 -0
  98. package/src/nextjs/README.md +756 -0
  99. package/src/route/README.md +763 -0
  100. package/src/security/README.md +100 -0
  101. package/src/server/README.md +704 -0
  102. package/src/state/README.md +806 -0
  103. package/dist/token-manager-BT5EnUAR.d.ts +0 -278
  104. package/dist/types-9oszaJqp.d.ts +0 -250
@@ -0,0 +1,845 @@
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 work it was registered with 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 has **hook queues of its own**: a hook belongs to the savepoint that
309
+ registered it. A savepoint that resolves hands its hooks to its parent; one that rolls
310
+ back runs its `onAfterRollback` callbacks right there — inside the **parent's** context,
311
+ the root still open — and drops its `onBeforeCommit` / `onAfterCommit` callbacks, even
312
+ when the root goes on to commit. See
313
+ [Hooks belong to the frame that registered them](#hooks-belong-to-the-frame-that-registered-them).
314
+ - **One at a time.** Nested calls made off the same transaction are serialized: a frame
315
+ opens only after the previous frame on that connection has closed. See
316
+ [Concurrent nested calls](#concurrent-nested-calls-are-serialized).
317
+ - `timeout` passed to a nested call is ignored; the root's timeout is already in force on
318
+ the shared connection and governs. A `warn` is logged only if you passed a `timeout`
319
+ explicitly — inheriting the root's is the normal case and says nothing worth logging.
320
+ - An inner rollback unwinds to its savepoint; the outer can catch and continue issuing
321
+ statements. This holds for a failed *statement* too: inside a savepoint, PostgreSQL's
322
+ aborted-transaction state (`25P02`) unwinds to the savepoint rather than poisoning the
323
+ whole transaction.
324
+
325
+ > **⚠️ BREAKING (from the release that introduced this)**
326
+ >
327
+ > A nested call **used to open an independent transaction on a second pooled connection**,
328
+ > despite these docs. It committed on its own, could not see the outer transaction's
329
+ > uncommitted writes, and self-deadlocked on rows the outer transaction had locked. It is now
330
+ > a SAVEPOINT, as documented.
331
+ >
332
+ > **Code that relied on a nested call committing independently must now pass
333
+ > `requiresNew: true`.** The usual case is an audit or failed-attempt record that must
334
+ > survive the outer rollback. Everything else — the overwhelming majority of nesting, where
335
+ > the inner work is simply part of the outer unit — needs no change and silently gets
336
+ > correct behavior.
337
+ >
338
+ > **Second dimension: nested calls made off one transaction no longer run concurrently.**
339
+ > They used to hold a connection each, so `Promise.all` over them really did overlap. They
340
+ > now share the outer transaction's connection, where overlapping savepoints corrupt each
341
+ > other, so the runner serializes them — `Promise.all([nestedA(), nestedB()])` still
342
+ > resolves with both results, just one after the other, and a call chain that fanned out
343
+ > N nested calls for latency loses that parallelism. Give a branch `requiresNew: true` to
344
+ > get its own connection back, and read
345
+ > [Concurrent nested calls](#concurrent-nested-calls-are-serialized) for the one shape that
346
+ > deadlocks.
347
+
348
+ ### `requiresNew: true` — opt back out into an independent transaction
349
+
350
+ Available on both `runInTransaction` and `Transactional()`. It opens a real `BEGIN` on a
351
+ second pooled connection, ignoring the ambient transaction entirely:
352
+
353
+ ```typescript
354
+ await runInTransaction(async () =>
355
+ {
356
+ await orderRepo.create(order);
357
+
358
+ // Commits on its own — lands even though the outer transaction rolls back below.
359
+ await runInTransaction(
360
+ () => auditRepo.record('order.attempted', order.id),
361
+ { requiresNew: true },
362
+ );
363
+
364
+ await inventoryRepo.reserve(order.items); // throws → outer rolls back
365
+ });
366
+ ```
367
+
368
+ Being a root transaction, it gets its own `statement_timeout`, its own
369
+ `idle_in_transaction_session_timeout`, and its **own** hook queues: `onBeforeCommit` /
370
+ `onAfterCommit` / `onAfterRollback` registered inside it fire on **its** outcome, not the
371
+ outer transaction's. `getTransactionContext()?.level` reads `1` inside it, however deeply it
372
+ sits lexically — `level` counts savepoint depth, and this call is a root.
373
+
374
+ Two costs, both consequences of the second connection — this is where the pool-starvation
375
+ caveat that used to apply to *every* nested call now lives:
376
+
377
+ - It holds a **second connection** for its whole duration, so the call chain counts twice
378
+ against the pool. Keep it short, and don't fan it out.
379
+ - It cannot see the outer transaction's uncommitted writes, and it **blocks** on any row the
380
+ outer transaction has locked. Since the outer transaction is waiting for this call to
381
+ return, that block is a **self-deadlock** that only `statement_timeout` breaks. Never touch
382
+ rows the outer transaction wrote.
383
+
384
+ ### Concurrent nested calls are serialized
385
+
386
+ Two nested calls under `Promise.all` would be two savepoints on **one** connection, and
387
+ `ROLLBACK TO` unwinds the connection, not a branch of it — so a failing sibling would
388
+ discard everything written since its savepoint, including the other sibling's rows, while
389
+ that sibling reported success. The runner therefore **queues nested frames per transaction**:
390
+ a frame opens only after the frame before it on that connection has closed.
391
+
392
+ ```typescript
393
+ // Still correct — and now genuinely atomic per branch. Just not parallel:
394
+ // stepB's SAVEPOINT is taken after stepA's frame has closed.
395
+ await Promise.all([
396
+ runInTransaction(() => stepA()),
397
+ runInTransaction(() => stepB()),
398
+ ]);
399
+ ```
400
+
401
+ The queue belongs to the transaction the frames are opened off, so it never blocks depth
402
+ (a nested call inside `stepA` waits on `stepA`'s own queue, which is empty) and never
403
+ touches `requiresNew`, which runs on a connection of its own.
404
+
405
+ - **You lose the parallelism, not the results.** Both branches still run and both results
406
+ still come back. If the fan-out existed for latency, give each branch `requiresNew: true`
407
+ — separate connections, genuinely concurrent, and each commits on its own instead of with
408
+ the root.
409
+ - **⚠️ One shape deadlocks: a nested call whose callback awaits a sibling that was started
410
+ after it.** The waiting frame holds the queue, so the sibling behind it can never open,
411
+ and nothing breaks the cycle — `statement_timeout` does not fire, because no statement is
412
+ running. This is misuse rather than a guarded case: the runner cannot see which promise a
413
+ callback is blocked on, and a wait limit would have to guess how long a legitimate sibling
414
+ may run — aborting slow-but-correct transactions. The first time frames contend, one `warn`
415
+ ("Concurrent nested transactions are serialized") is logged per process, so the hazard is
416
+ on the record before it can hang.
417
+
418
+ ```typescript
419
+ // ❌ Deadlock: A entered the queue first, so it holds it — and it is waiting for
420
+ // B, which is queued behind A and can never open. Frames open in the order the
421
+ // calls were made, so this needs the awaited sibling to be created AFTER the
422
+ // waiting one; the reverse (create B first, await it from A) is fine, because
423
+ // B has already run by the time A opens.
424
+ const a = runInTransaction(async () => { await b; });
425
+ const b = runInTransaction(() => stepB());
426
+
427
+ await Promise.all([a, b]);
428
+ ```
429
+
430
+ Statements issued **directly** on the outer transaction have the same hazard and no queue
431
+ to protect them: `Promise.all([tx.insert(...), runInTransaction(() => stepB())])` writes the
432
+ outer row after `stepB`'s savepoint was taken, so `stepB` failing discards it. Don't overlap
433
+ outer-transaction work with an open nested frame either.
434
+
435
+ ### Operational caveats of the driver's savepoints
436
+
437
+ Verified against the pinned driver (`drizzle-orm` 1.0.0-rc.4 over `postgres` 3.4.7) by
438
+ logging the SQL it emits — worth knowing before nesting deeply or in a loop:
439
+
440
+ - **`RELEASE SAVEPOINT` is never issued.** The driver emits `savepoint "sN"` on entry and
441
+ `rollback to "sN"` on failure, and nothing at all on success — the savepoint simply stays
442
+ defined until the root transaction ends. Names are unique per transaction (`s0`, `s1`, …),
443
+ so nothing shadows anything; they just accumulate.
444
+ - **A write inside a nested frame costs a subtransaction, and the backend caches 64.** Each
445
+ savepoint that writes assigns a subtransaction id. PostgreSQL keeps 64 of them per backend
446
+ (`PGPROC_MAX_CACHED_SUBXIDS`); beyond that the backend is marked *suboverflowed*, and other
447
+ sessions' visibility checks stop being answerable from shared memory and go to `pg_subtrans`
448
+ on disk instead — a cliff that slows the **whole cluster's** snapshot checks, not just this
449
+ transaction. Measured on PostgreSQL 16.15: `subxact_count` climbs to 64 and
450
+ `subxact_overflowed` flips to `true` on the 65th write-savepoint.
451
+ - **Practical rule:** don't put a nested `runInTransaction` inside a loop over more than a
452
+ few dozen rows. Do the batch as one statement, or as plain repository calls in the outer
453
+ transaction — nesting per row buys nothing unless you need per-row rollback.
454
+
455
+ ---
456
+
457
+ ## Transaction hooks
458
+
459
+ Three hooks attach work to a transaction's lifecycle. All three take
460
+ `() => void | Promise<void>`, are registered the same way from anywhere inside the async
461
+ call chain, and queue on the **frame** — root or savepoint — they were registered in
462
+ (`BeforeCommitCallback`, `AfterCommitCallback`, `AfterRollbackCallback` are the exported
463
+ type aliases). A frame's queues reach the root only through savepoints that resolved; see
464
+ [Hooks belong to the frame that registered them](#hooks-belong-to-the-frame-that-registered-them).
465
+
466
+ | Hook | Runs | Still inside the tx? | If the callback throws |
467
+ |------|------|----------------------|------------------------|
468
+ | `onBeforeCommit(cb)` | after the root callback resolves, **before** `COMMIT` | **yes** — may run statements | aborts: later callbacks skipped, transaction rolls back, error propagates, `afterRollback` fires |
469
+ | `onAfterCommit(cb)` | after the root transaction committed | no | logged, never thrown |
470
+ | `onAfterRollback(cb)` | after the root transaction rolled back — or, registered in a savepoint that rolled back, right after that savepoint did | no after a root rollback; after a savepoint rollback it runs inside the **parent's** still-open transaction | logged, never thrown; the **original** error keeps propagating unchanged |
471
+
472
+ Registration context:
473
+
474
+ | Context | `onBeforeCommit` | `onAfterCommit` | `onAfterRollback` |
475
+ |---------|------------------|-----------------|-------------------|
476
+ | Inside root transaction | Queued; runs before the root's commit | Queued; runs after the root commits | Queued; runs if the root rolls back |
477
+ | Inside a nested transaction | Queued on the savepoint; passed to the parent if it resolves, dropped if it rolls back | Queued on the savepoint; passed to the parent if it resolves, dropped if it rolls back | Queued on the savepoint; passed to the parent if it resolves, **run at once** if it rolls back |
478
+ | 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 |
479
+
480
+ ### `onBeforeCommit(callback)`
481
+
482
+ The last moment the transaction is still open. Use it for cross-cutting work that must be
483
+ part of the same commit — a final invariant check, an audit row, a denormalized counter —
484
+ without threading it through every call site.
485
+
486
+ ```typescript
487
+ import { runInTransaction, onBeforeCommit } from '@spfn/core/db';
488
+
489
+ async function transfer(fromId: string, toId: string, amount: number)
490
+ {
491
+ // The transaction is what gives the check teeth — see the warning below.
492
+ await runInTransaction(async () =>
493
+ {
494
+ await accountRepo.debit(fromId, amount);
495
+ await accountRepo.credit(toId, amount);
496
+
497
+ // Runs inside the transaction: a throw rolls the whole transfer back.
498
+ onBeforeCommit(() => assertNoNegativeBalance(fromId));
499
+ });
500
+ }
501
+ ```
502
+
503
+ - Runs **inside** the transaction context: `getTransaction()` returns the live `tx`, and
504
+ repositories called from a callback join the same transaction, so their writes are part
505
+ of the same commit.
506
+ - Runs in registration order, one at a time, and only after the user callback resolved
507
+ successfully — the runner never starts this pass on a transaction that already failed.
508
+ (If your own code *swallowed* a statement error, PostgreSQL has aborted the transaction
509
+ and every statement here fails with `25P02` — but so would the `COMMIT`; the hook only
510
+ surfaces that earlier.)
511
+ - A throw is not caught: later callbacks are skipped, the transaction rolls back, the error
512
+ propagates to the caller, and `afterRollback` callbacks fire.
513
+ - **Registered outside a transaction, the hook keeps none of that.** The callback runs
514
+ immediately on a microtask and a `warn` is logged: there is nothing to abort, so a
515
+ throwing invariant check is swallowed into a log line while the write it meant to prevent
516
+ has already committed. Always register it from inside `runInTransaction`/`Transactional`.
517
+ - The queue is **snapshot before the pass**: a callback registered *by* a beforeCommit
518
+ callback does not run for this commit. Growing the queue mid-iteration would loop forever
519
+ inside the open transaction (with `statement_timeout` powerless — no statement is
520
+ running), so the snapshot is deliberate, not incidental.
521
+ - Calling `onAfterCommit` from a beforeCommit callback works — the queue is read after the
522
+ beforeCommit pass.
523
+
524
+ ### `onAfterCommit(callback)`
525
+
526
+ Defer a side effect (notifications, jobs, analytics, cache busting) until **after** the data
527
+ is durably committed.
528
+
529
+ ```typescript
530
+ import { onAfterCommit } from '@spfn/core/db';
531
+
532
+ async function submit(spaceId: string, chatId: string)
533
+ {
534
+ const publication = await publicationRepo.create({ spaceId, chatId });
535
+ await requestRepo.updateStatusAtomically(requestId, 'submitted');
536
+
537
+ onAfterCommit(() => generateArticle(spaceId, chatId, publication.id));
538
+
539
+ return publication;
540
+ }
541
+ ```
542
+
543
+ - Callbacks run **outside** the transaction context — a `getTransaction()` inside one
544
+ returns `null`, so DB work uses a fresh connection (a new transaction, not this one).
545
+ - Fire-and-forget: each callback runs via `Promise.resolve().then(cb).catch(log)`. Errors
546
+ are logged, never thrown, and never affect the (already committed) transaction.
547
+ - Execution is FIFO in registration order; multiple callbacks per transaction are fine.
548
+ - If the transaction **rolls back**, queued callbacks never run.
549
+
550
+ ### `onAfterRollback(callback)`
551
+
552
+ Compensate for non-transactional work once the transaction is known to be gone — delete an
553
+ uploaded object, release a reserved external id, mark a cached intent as failed.
554
+
555
+ ```typescript
556
+ import { runInTransaction, onAfterRollback } from '@spfn/core/db';
557
+
558
+ async function importAvatar(userId: string, file: Blob)
559
+ {
560
+ // Upload first: external I/O never belongs inside the transaction.
561
+ const key = await objectStore.put(file);
562
+
563
+ await runInTransaction(async () =>
564
+ {
565
+ await userRepo.updateAvatar(userId, key);
566
+
567
+ // The upload cannot roll back on its own — undo it if the write never lands.
568
+ onAfterRollback(() => objectStore.delete(key));
569
+ });
570
+ }
571
+ ```
572
+
573
+ - Fires on **any** root rollback: a thrown handler error, a thrown `onBeforeCommit`
574
+ callback, a statement timeout, a constraint violation.
575
+ - Registered inside a savepoint, it fires when **that savepoint** rolls back — at that
576
+ moment, whatever the root does afterwards — and not again at the root.
577
+ - After a **root** rollback, callbacks run **outside** the transaction context
578
+ (`getTransaction()` is `null`) — DB work in them uses a fresh connection, like
579
+ `onAfterCommit`. After a **savepoint** rollback the root is still open and they run
580
+ inside the **parent's** context: `getTransaction()` is the parent's `tx`, and DB work in
581
+ them joins the parent transaction — it commits only if the parent does.
582
+ - They are **awaited**, in registration order, before the causing error leaves
583
+ `runInTransaction` / the middleware (for a savepoint: the nested call). A callback that
584
+ hangs delays that error, so keep them short.
585
+ - Errors are logged and swallowed: a failing callback never replaces the error that caused
586
+ the rollback, never stops the remaining callbacks, and neither does a failure of the log
587
+ call itself.
588
+ - The trigger is "the transaction did not report success", which is *almost* always a
589
+ rollback. A connection lost at exactly the `COMMIT` leaves the outcome genuinely unknown
590
+ to the client, and these callbacks fire — so a compensation should be idempotent and
591
+ safe to run against data that did, in the end, land.
592
+
593
+ ### Hooks belong to the frame that registered them
594
+
595
+ Every frame — the root and each savepoint — has queues of its own, and its **own** fate
596
+ decides what happens to the hooks registered in it:
597
+
598
+ - **A savepoint that resolves** appends its three queues to its parent's, after what the
599
+ parent registered before the nested call and before what it registers after. Its hooks
600
+ now share the parent's fate: `onBeforeCommit` still runs before the **root's** `COMMIT`
601
+ (not at the savepoint — the driver never releases one), `onAfterCommit` after the root
602
+ commits, `onAfterRollback` if the root rolls back. Promotion repeats level by level, so a
603
+ hook fires exactly once, and only if every savepoint between it and the root resolved.
604
+ - **A savepoint that rolls back** drops its `onBeforeCommit` and `onAfterCommit`
605
+ callbacks — the work they guard or announce is gone — and runs its `onAfterRollback`
606
+ callbacks immediately, awaited before the error leaves the nested call, inside the
607
+ parent's still-open transaction. Hooks promoted into it from savepoints nested inside it
608
+ go the same way. A compensation may call `runInTransaction` itself: that opens a savepoint
609
+ on the parent, and a hook the compensation registers belongs to the parent.
610
+ - **A statement that fails inside a savepoint's `onAfterRollback` aborts the parent.** The
611
+ root-rollback pass runs outside any transaction, so a failing statement there touches
612
+ nothing; the savepoint pass runs inside the parent's transaction, so a failing statement
613
+ puts it into the aborted state (`25P02`). The callback's error is logged and swallowed as
614
+ always, but the root's next statement then fails with `25P02` (current transaction is
615
+ aborted). If the root runs none, its `COMMIT` fails with the callback's error. Either way,
616
+ everything the root wrote rolls back. Wrap DB work in a compensation in its own
617
+ `runInTransaction` (a savepoint) when it may fail.
618
+ - **The root** runs whatever reached it. A savepoint failure the root catches and survives
619
+ fires none of the **root's** own `afterRollback` callbacks: the root did not roll back.
620
+ - A `requiresNew: true` call is a root on its own connection: hooks registered inside it
621
+ belong to it and fire on its own commit or rollback, never handed to the outer
622
+ transaction.
623
+ - **A hook registered after its frame has settled is dropped**, the same as one registered
624
+ after a root has settled. A frame's queues are handed on (or discarded) once, at the
625
+ moment it settles; detached work it started — a `setTimeout`, an un-awaited promise —
626
+ that registers a hook later appends to a queue nobody reads again. Register hooks in the
627
+ awaited call chain of the frame they belong to.
628
+
629
+ ### Why there is no `onBeforeRollback`
630
+
631
+ The moment does not exist. A rollback is triggered by a statement error, which puts the
632
+ PostgreSQL session into the aborted-transaction state (`25P02`): every subsequent statement
633
+ except `ROLLBACK` fails with *"current transaction is aborted, commands ignored until end of
634
+ transaction block"*. A hook there could not read, write, or log to the database — the only
635
+ thing it could do is non-DB work, which belongs in `onAfterRollback`, where it runs on a
636
+ healthy connection.
637
+
638
+ ---
639
+
640
+ ## Low-level: `getTransaction` / `getTransactionContext` / `runWithTransaction`
641
+
642
+ You normally don't touch these — `BaseRepository` already resolves the transaction for you.
643
+ Reach for them only when writing a custom wrapper, or for a repository-less helper that must
644
+ manually honor an ambient transaction.
645
+
646
+ ```typescript
647
+ import { getTransaction, getTransactionContext, runWithTransaction } from '@spfn/core/db';
648
+
649
+ getTransaction(); // TransactionDB | null
650
+ getTransactionContext(); // { tx, txId, level, ...hook queues, nestedFrames } | null
651
+
652
+ // Bind an existing Drizzle tx so nested code sees it via getTransaction().
653
+ // NOTE the 3-arg signature: (tx, txId, callback)
654
+ await db.transaction(async (tx) =>
655
+ {
656
+ return await runWithTransaction(tx, `tx_${crypto.randomUUID()}`, async () =>
657
+ {
658
+ // getTransaction() === tx here and in everything this calls
659
+ return doWork();
660
+ });
661
+ });
662
+ ```
663
+
664
+ > Prefer `runInTransaction` over hand-rolling `db.transaction()` + `runWithTransaction`:
665
+ > the runner adds the `txId`, timeout enforcement, slow-tx logging, and the hook-queue
666
+ > plumbing that raw `runWithTransaction` does not. `runWithTransaction` creates the queues,
667
+ > but only the runner ever fires them — hooks registered under a hand-rolled
668
+ > `runWithTransaction` never run.
669
+
670
+ ---
671
+
672
+ ## Pitfalls & anti-patterns
673
+
674
+ - **Never do external I/O inside a transaction.** A transaction holds a pooled connection
675
+ (and any row locks taken) from `BEGIN` to `COMMIT`. If the handler awaits an external API,
676
+ queue, or other non-DB work while the transaction is open, that connection sits idle but
677
+ reserved — under load, in-flight requests cap out at the pool size and everything else
678
+ queues. Route-level `Transactional()` wraps the **whole handler**, so it's especially easy
679
+ to fall into; prefer scoping the transaction to the DB statements (call `runInTransaction`
680
+ inside a service around just the writes). The `idle_in_transaction_session_timeout` backstop
681
+ reaps the worst case, and a "Slow transaction" `warn` flags offenders — but the fix is to
682
+ move the I/O out. If you have a write → external-call → write flow where the external call
683
+ has a side effect (charge, send), don't span it with a transaction at all — commit intent,
684
+ call outside the transaction, then commit the result (outbox / saga).
685
+ - **Import from `@spfn/core/db`, not `@spfn/core/db/transaction` or `@spfn/core`.** Neither
686
+ of the latter is a real package export — they don't resolve.
687
+ - **`runWithTransaction` is `(tx, txId, callback)`.** Calling it with two args silently
688
+ binds your callback into the `txId` slot. If you only have repositories, you don't need
689
+ this function at all — start a transaction with `runInTransaction`/`Transactional`.
690
+ - **Don't pass `tx` around manually when using repositories.** Repositories read the
691
+ ambient transaction via `getTransaction()`. Threading a `tx` parameter is redundant and
692
+ invites bugs where one path forgets it.
693
+ - **Don't nest a raw `db.transaction()` without `runWithTransaction`.** A raw
694
+ `db.transaction()` opens a Drizzle SAVEPOINT but does **not** update `AsyncLocalStorage`,
695
+ so repositories inside it still use the *outer* tx, not the savepoint — your "inner"
696
+ rollback won't isolate as expected. Use `runInTransaction` (which wires both) instead.
697
+ - **`timeout` on a nested call is ignored.** Set the timeout on the outermost
698
+ transaction/middleware; an explicitly passed nested value is dropped with a warning, and
699
+ the root's timeout governs the savepoint anyway.
700
+ - **Concurrent nested calls are serialized, and one shape of them deadlocks.** Sibling
701
+ savepoints share the outer transaction's connection, so the runner queues them; `Promise.all`
702
+ over nested calls is correct but no longer parallel. A nested call whose callback *awaits a
703
+ sibling started after it* hangs forever — see
704
+ [Concurrent nested calls](#concurrent-nested-calls-are-serialized).
705
+ - **Don't open a nested transaction per row in a loop.** Each writing savepoint burns one of
706
+ the backend's 64 cached subtransaction ids and is never released before the root ends; past
707
+ that the backend goes suboverflowed and every other session's visibility checks get slower.
708
+ - **`requiresNew: true` costs a second connection and can self-deadlock.** Use it only when
709
+ the inner work must survive an outer rollback (an audit row, a failed-attempt record), keep
710
+ it short, and never let it touch rows the outer transaction wrote.
711
+ - **Side effects belong in `onAfterCommit`, not the handler body.** Calling a notifier /
712
+ job / external API directly in the handler runs it *before* commit and holds the
713
+ transaction (and its pooled connection) open. If the tx later rolls back, you've already
714
+ fired the side effect.
715
+ - **`onAfterCommit` DB work runs in a new connection, not this transaction.** It executes
716
+ after the context is gone, so `getTransaction()` is `null` inside it — its writes are a
717
+ separate transaction and won't roll back with the original.
718
+ - **No external I/O in `onBeforeCommit`.** It runs *inside* the transaction, so an API call
719
+ there holds the connection open exactly like one in the handler body. Keep it to DB work
720
+ and cheap invariant checks; anything else belongs in `onAfterCommit`.
721
+ - **`onAfterRollback` is not a "retry the write" hook.** It runs after the transaction is
722
+ gone, on a fresh connection. Use it to undo work that was never transactional in the first
723
+ place (an upload, an external reservation), not to re-attempt the failed statements.
724
+ - **Keep transactions short.** No network I/O / file uploads inside the tx — do that work
725
+ first, then run only the DB writes in the transaction (see example below). Long
726
+ transactions hold connections and can exhaust the pool.
727
+ - **`getTransaction()` returns `null` outside a transaction.** It is not a "get me a db"
728
+ helper. For plain DB access use a `BaseRepository` or `getDatabase()`.
729
+
730
+ ```typescript
731
+ // ❌ Long transaction: external work holds the tx + connection open
732
+ .use([Transactional()])
733
+ .handler(async (c) =>
734
+ {
735
+ const data = await fetch('https://api.example.com').then(r => r.json()); // I/O in tx
736
+ await userRepo.create(data);
737
+ await uploadFile(file); // I/O in tx
738
+ return { ok: true };
739
+ });
740
+
741
+ // ✅ External work first, DB writes only inside the transaction
742
+ .handler(async (c) =>
743
+ {
744
+ const data = await fetch('https://api.example.com').then(r => r.json());
745
+ await uploadFile(file);
746
+
747
+ return runInTransaction(async () =>
748
+ {
749
+ const user = await userRepo.create(data);
750
+ onAfterCommit(() => notify(user.id)); // side effect after commit
751
+ return user;
752
+ });
753
+ });
754
+ ```
755
+
756
+ ---
757
+
758
+ ## Complete example
759
+
760
+ ```typescript
761
+ // routes/publications.ts
762
+ import { route } from '@spfn/core/route';
763
+ import { Transactional, onAfterCommit } from '@spfn/core/db';
764
+ import { Type } from '@sinclair/typebox';
765
+
766
+ export const submit = route.post('/publications')
767
+ .input({ body: Type.Object({ spaceId: Type.String(), chatId: Type.String() }) })
768
+ .use([Transactional({ timeout: 15000, slowThreshold: 2000 })])
769
+ .handler(async (c) =>
770
+ {
771
+ const { body } = await c.data();
772
+
773
+ // Repositories auto-join the middleware's transaction.
774
+ const publication = await publicationRepo.create(body);
775
+ await requestRepo.updateStatusAtomically(body.chatId, 'submitted');
776
+
777
+ // Fires only after the transaction commits; runs on a fresh connection.
778
+ onAfterCommit(() => generateArticle(body.spaceId, body.chatId, publication.id));
779
+
780
+ return publication; // commit; a throw here → rollback (PG errors normalized)
781
+ });
782
+ ```
783
+
784
+ ```typescript
785
+ // scripts/backfill.ts — same propagation, no Hono
786
+ import { runInTransaction } from '@spfn/core/db';
787
+
788
+ await runInTransaction(async (tx) =>
789
+ {
790
+ const rows = await tx.select().from(legacy);
791
+ for (const row of rows)
792
+ {
793
+ await userRepo.create(mapLegacy(row)); // joins this tx via getTransaction()
794
+ }
795
+ }, { context: 'script:backfill', timeout: 0 /* disable timeout for a long backfill */ });
796
+ ```
797
+
798
+ ---
799
+
800
+ ## Types reference
801
+
802
+ ```typescript
803
+ type TransactionDB<TDatabase = DefaultDatabase> = DatabaseTransaction<TDatabase>;
804
+
805
+ type BeforeCommitCallback = () => void | Promise<void>;
806
+ type AfterCommitCallback = () => void | Promise<void>;
807
+ type AfterRollbackCallback = () => void | Promise<void>;
808
+
809
+ type TransactionContext<TDatabase = DrizzleDatabase> = {
810
+ tx: TransactionDB<TDatabase>; // live Drizzle transaction
811
+ txId: string; // "tx_<uuid>" — tracing id
812
+ level: number; // nesting depth, root = 1
813
+ beforeCommitCallbacks: BeforeCommitCallback[]; // shared with the root
814
+ afterCommitCallbacks: AfterCommitCallback[]; // shared with the root
815
+ afterRollbackCallbacks: AfterRollbackCallback[]; // shared with the root
816
+ nestedFrames: NestedFrameGate; // own — queues THIS context's frames
817
+ };
818
+
819
+ type NestedFrameGate = {
820
+ run<T>(frame: () => Promise<T>): Promise<T>; // internal; the runner calls it
821
+ };
822
+
823
+ interface TransactionalOptions {
824
+ slowThreshold?: number; // default 1000 (ms)
825
+ enableLogging?: boolean; // default true
826
+ timeout?: number; // default 30000 / env.TRANSACTION_TIMEOUT (ms)
827
+ idleTimeout?: number; // default 30000 / env.TRANSACTION_IDLE_TIMEOUT (ms)
828
+ requiresNew?: boolean; // default false — independent tx instead of a SAVEPOINT
829
+ }
830
+
831
+ interface RunInTransactionOptions {
832
+ slowThreshold?: number; // default 1000 (ms)
833
+ enableLogging?: boolean; // default true
834
+ timeout?: number; // default env.TRANSACTION_TIMEOUT (30000 ms)
835
+ idleTimeout?: number; // default env.TRANSACTION_IDLE_TIMEOUT (30000 ms)
836
+ context?: string; // default 'transaction'
837
+ requiresNew?: boolean; // default false — independent tx instead of a SAVEPOINT
838
+ }
839
+ ```
840
+
841
+ ## Related
842
+
843
+ - [@spfn/core/db](../README.md) — database connection, `BaseRepository`, `getDatabase`
844
+ - [@spfn/core/config](../../config/README.md) — `TRANSACTION_TIMEOUT` and other settings
845
+ - [@spfn/core/errors](../../errors/README.md) — `TransactionError`, `DatabaseError`, `fromPostgresError` results