@spfn/core 0.3.0-beta.4 → 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.
- package/README.md +183 -4
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +6 -387
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -6
- package/dist/ops/index.js +330 -30
- package/dist/ops/index.js.map +1 -1
- package/dist/server/index.js +24 -1
- package/dist/server/index.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
|
@@ -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
|