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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -185,18 +185,144 @@ import type { AppRouter } from '@/server/router';
185
185
 
186
186
  export const api = createApi<AppRouter>();
187
187
 
188
- // anywhere server component, client component, or server action:
188
+ // the same client in a Server Component, a Client Component or a Server Action:
189
189
  const user = await api.getUser.call({ params: { id: '123' } }); // typed { id, name }
190
190
  const made = await api.createUser.call({ body: { name: 'A' } });
191
191
  ```
192
192
 
193
+ That the client is isomorphic does not make the three callers interchangeable. **A page's
194
+ initial data is awaited in the Server Component**, where
195
+ `api.getUser.fetchOptions({ next: { revalidate, tags } }).call(…)` participates in
196
+ Next.js caching and reaches the backend without a browser round trip. Fetching that same
197
+ first paint from a `'use client'` component inside a `useEffect` is the anti-pattern: it
198
+ ships a loading state and a second network hop for data the server already had. Client
199
+ Components and Server Actions are for what happens after the first paint — interaction
200
+ and mutation. Cache tags, revalidation and SSR cookie forwarding are in
201
+ [the Next.js bridge docs](https://superfunction.xyz/docs/packages/core/nextjs).
202
+
203
+ ---
204
+
205
+ ## How does a repository talk to the database?
206
+
207
+ Through `BaseRepository`. Extending it gives a repository two transaction-aware
208
+ connections — `this.db` (write/primary) and `this.readDb` (the replica, when one is
209
+ configured) — plus the CRUD set as protected methods.
210
+
211
+ ```typescript
212
+ // server/repositories/order.ts
213
+ import { BaseRepository } from '@spfn/core/db';
214
+ import { desc } from 'drizzle-orm';
215
+ import { orders } from '../entities/order';
216
+
217
+ export class OrderRepository extends BaseRepository
218
+ {
219
+ findRecentFor(userId: string)
220
+ {
221
+ return this._findMany(orders, { where: { userId }, orderBy: desc(orders.createdAt) });
222
+ }
223
+
224
+ place(data: { userId: string; total: number })
225
+ {
226
+ return this._create(orders, data);
227
+ }
228
+ }
229
+
230
+ export const orderRepo = new OrderRepository();
231
+ ```
232
+
233
+ Handlers never import drizzle query builders; repositories do. `_findMany` reads through
234
+ `this.readDb`, `_create` writes through `this.db`, and both getters resolve to the active
235
+ transaction's connection when there is one — so the same method is correct inside a
236
+ transaction and outside it. When a helper cannot express a query, drop to
237
+ `this.readDb.select()…` inside the repository rather than in the handler. The full
238
+ protected CRUD set is in [src/db](./src/db/README.md).
239
+
240
+ ---
241
+
242
+ ## Where do transactions and their side effects go?
243
+
244
+ `Transactional()` covers the route case — commit on return, rollback on throw. Two rules
245
+ decide the rest.
246
+
247
+ **Nothing takes a `tx` parameter.** The transaction travels in AsyncLocalStorage, so
248
+ `this.db` inside a repository already resolves to it. A service that accepts `tx` and
249
+ threads it downward re-implements propagation that already happened, and the first caller
250
+ that forgets to pass it writes outside the transaction. For a service, script or job with
251
+ no route around it, open one with `runInTransaction(fn, options?)`;
252
+ `runWithTransaction(tx, txId, fn)` is the lower-level primitive that binds an existing
253
+ Drizzle transaction into the context.
254
+
255
+ **Side effects go on the commit hooks, not inline.** An event emitted or a mail sent from
256
+ inside the transaction still went out when the transaction later rolls back.
257
+
258
+ | Hook | When it runs | What it is for |
259
+ |---|---|---|
260
+ | `onBeforeCommit(fn)` | Inside the still-open transaction, just before commit — a throw aborts and rolls back | Last-moment invariant checks, and statements that must land in the same commit |
261
+ | `onAfterCommit(fn)` | After the root transaction commits, outside the transaction context; errors are logged, never thrown | Events, mail, cache invalidation — anything the outside world observes |
262
+ | `onAfterRollback(fn)` | After the root transaction rolls back, before the causing error propagates; errors are logged, never thrown | Undoing external work that cannot roll itself back, such as an object already uploaded |
263
+
264
+ All three import from `@spfn/core/db`, can be registered anywhere inside the transaction,
265
+ and bubble to the **root** transaction — a nested block's callbacks fire on the outermost
266
+ outcome, not on a savepoint's.
267
+
268
+ ```typescript
269
+ import { runInTransaction, onAfterCommit } from '@spfn/core/db';
270
+
271
+ export async function placeOrder(input: { userId: string; total: number })
272
+ {
273
+ return runInTransaction(async () =>
274
+ {
275
+ const order = await orderRepo.place(input); // no tx argument, at any depth
276
+ onAfterCommit(() => orderPlacedEvent.emit({ orderId: order.id }));
277
+
278
+ return order;
279
+ });
280
+ }
281
+ ```
282
+
283
+ ---
284
+
285
+ ## What else can defineServerConfig configure?
286
+
287
+ `.port()` and `.routes()` are the two every app calls. The rest of the builder is how an
288
+ app wires its infrastructure without touching the server's boot sequence. Every method
289
+ returns the builder; `.build()` ends the chain.
290
+
291
+ | Method | What it configures |
292
+ |---|---|
293
+ | `.port(n)` / `.host(s)` | Where the server listens |
294
+ | `.routes(router)` | The `defineRouter` router to mount, with its own `.use()` and `.packages()` |
295
+ | `.jobs(router, config?)` | Background jobs — a `defineJobRouter`, plus pg-boss options |
296
+ | `.events(router, config?)` | SSE streaming — a `defineEventRouter`, served at `GET /events/stream` |
297
+ | `.websockets(router, config?)` | Bidirectional WebSockets — a `defineWSRouter`, served at `WS /ws` |
298
+ | `.workflows(router, config?)` | `@spfn/workflow` orchestration; the engine starts once the database is ready |
299
+ | `.lifecycle(hooks)` | Boot and shutdown hooks. Callable more than once; hooks run in registration order |
300
+ | `.migrations(opts)` | The migration boot gate — `{ allowPending: true }` lets a server start behind its migrations |
301
+ | `.database(opts)` | Connection and pool settings |
302
+ | `.infrastructure(opts)` | Which infrastructure is initialized at boot |
303
+ | `.healthCheck(opts)` | The health endpoint |
304
+ | `.serverTime(clock)` | The clock behind `GET /_core/time`; normally left at the default |
305
+ | `.middleware(opts)` | The built-in middleware — `ErrorHandler`, `RequestLogger` |
306
+ | `.use(handlers)` | Additional global Hono middleware |
307
+ | `.middlewares(named)` | Global middleware under names, so a route can `.skip([...])` it |
308
+ | `.cors(opts)` | CORS |
309
+ | `.rateLimit(opts)` | A global default limiter plus the named policies routes resolve against |
310
+ | `.proxyGuard(opts)` | Trusted-proxy signature and origin verification, resolved to a `clientType` |
311
+ | `.outboundFetch(opts)` | The SSRF policy `safeFetch` applies to outbound calls |
312
+ | `.timeout(opts)` / `.shutdown(opts)` | Request timeouts and graceful shutdown |
313
+ | `.debug(bool)` | Debug logging |
314
+
315
+ The options each one takes are in [src/server](./src/server/README.md).
316
+
193
317
  ---
194
318
 
195
319
  ## Which import path do I use for what?
196
320
 
197
321
  There is **no root barrel**: `import … from '@spfn/core'` does not resolve. Every symbol
198
322
  comes from a subpath, and the table below is the complete public surface — one row per
199
- entry in `package.json` `exports`. Each module has its own README with the API detail.
323
+ entry in `package.json` `exports`, with a single exclusion: `./client` is still listed in
324
+ `exports` but the build no longer emits it, so it has no row (see [Pitfalls](#pitfalls)).
325
+ Each module has its own README with the API detail.
200
326
 
201
327
  | Import path | Purpose | Doc |
202
328
  |-------------|---------|-----|
@@ -207,16 +333,17 @@ entry in `package.json` `exports`. Each module has its own README with the API d
207
333
  | `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
208
334
  | `@spfn/core/db` | PostgreSQL through Drizzle: CRUD helpers, `BaseRepository`, schema helpers, transactions, Postgres error mapping. One entry point for all of it. | [src/db](./src/db/README.md) |
209
335
  | `@spfn/core/db` → manager | Connection lifecycle, pool, primary/replica, health check, reconnect (`initDatabase`, `getDatabase`). Re-exported from `@spfn/core/db`. | [src/db/manager](./src/db/manager/README.md) |
210
- | `@spfn/core/db` → migrations | Which migrations each installed function package ships, and which the database has applied (`collectMigrationStatus`, `discoverFunctionMigrations`). What `spfn db status`, the boot gate and health all read. Re-exported from `@spfn/core/db`. | [src/db/migrations](./src/db/migrations/index.ts) |
336
+ | `@spfn/core/db` → migrations | Which migrations each installed function package ships, and which the database has applied (`collectMigrationStatus`, `discoverFunctionMigrations`). What `spfn db status`, the boot gate and health all read. Re-exported from `@spfn/core/db`. | [src/db/migrations](https://github.com/fxylabs/spfn/blob/main/packages/core/src/db/migrations/index.ts) |
211
337
  | `@spfn/core/db` → schema | Drizzle column helpers (`id`, `uuid`, `timestamps`, `foreignKey`, `enumText`, `typedJsonb`, `softDelete`, …). Re-exported from `@spfn/core/db`. | [src/db/schema](./src/db/schema/README.md) |
212
338
  | `@spfn/core/db` → transaction | `Transactional()` middleware and `runInTransaction`; the transaction reaches every repository through AsyncLocalStorage. Re-exported from `@spfn/core/db`. | [src/db/transaction](./src/db/transaction/README.md) |
213
339
  | `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` and its masking helper. | [src/middleware](./src/middleware/README.md) |
214
340
  | `@spfn/core/errors` | Serializable HTTP and database error classes, plus `ErrorRegistry` so an error survives the trip to the client as its own class. | [src/errors](./src/errors/README.md) |
215
341
  | `@spfn/core/security` | `safeFetch` — a drop-in `fetch` hardened against SSRF, including DNS rebinding, by pinning the connection to a validated IP. | [src/security](./src/security/README.md) |
216
- | `@spfn/core/authz` | Ownership guards. `requireOwner(resource, userId)` makes "load it, then check it belongs to the requester" one call, so a handler cannot forget it. | [src/authz/index.ts](./src/authz/index.ts) |
342
+ | `@spfn/core/authz` | Ownership guards. `requireOwner(resource, userId)` makes "load it, then check it belongs to the requester" one call, so a handler cannot forget it. | [src/authz/index.ts](https://github.com/fxylabs/spfn/blob/main/packages/core/src/authz/index.ts) |
217
343
  | `@spfn/core/env` | Schema-based environment validation, isomorphic. | [src/env](./src/env/README.md) |
218
344
  | `@spfn/core/env/loader` | The **server-only** `.env` file loader (uses `node:fs`). | [src/env](./src/env/README.md) |
219
345
  | `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`), built on `@spfn/core/env`. | [src/config](./src/config/README.md) |
346
+ | `@spfn/core/app-config` | Reads `spfn.config.js` — the one committed place that says which ports and host the app is served on (`loadAppConfig`, `resolvePorts`, `resolveHost`, `PORT_DEFAULTS`). Deliberately side-effect free, so the CLI can import it before an app's environment exists. | [src/app-config/index.ts](https://github.com/fxylabs/spfn/blob/main/packages/core/src/app-config/index.ts) |
220
347
  | `@spfn/core/logger` | Structured singleton `logger` with child loggers and level masking. No dependencies. | [src/logger](./src/logger/README.md) |
221
348
  | `@spfn/core/cache` | Valkey/Redis singleton over ioredis (`getCache`, `getCacheRead`). Degrades to disabled rather than throwing. | [src/cache](./src/cache/README.md) |
222
349
  | `@spfn/core/job` | Background jobs on pg-boss: a fluent `job()` builder, cron, run-once, event-driven, `defineJobRouter`. | [src/job](./src/job/README.md) |
@@ -227,6 +354,7 @@ entry in `package.json` `exports`. Each module has its own README with the API d
227
354
  | `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event](./src/event/README.md) |
228
355
  | `@spfn/core/codegen` | The codegen orchestrator and the built-in generators: `@spfn/core:route-map` for the proxy's route map, `@spfn/core:contract` for the client contract. | [src/codegen](./src/codegen/README.md) |
229
356
  | `@spfn/core/contract` | Route contracts for clients that ship separately: collect, snapshot, and the build gate that refuses a breaking change. | [src/contract](./src/contract/README.md) |
357
+ | `@spfn/core/ops` | The operations surface `spfn ops` drives: `opsRoute`, `createOpsRouter`, `defineOpsModule`, and the manifest the CLI discovers commands from. Structure only — the router is always authenticated, and token verification lives in `@spfn/auth`. | [How do I operate the app from the terminal?](#how-do-i-operate-the-app-from-the-terminal) |
230
358
 
231
359
  `db/manager`, `db/schema` and `db/transaction` are **not** package subpaths of their own.
232
360
  They are internal modules re-exported by `@spfn/core/db` — import their symbols from
@@ -397,27 +397,6 @@ declare function getDatabaseInfo(): {
397
397
  providerKind?: string;
398
398
  };
399
399
 
400
- /**
401
- * Reconnect Trigger — Query-error driven pool rebuild
402
- *
403
- * Complements the periodic health check with a fast-path: when application
404
- * queries start failing with connection-level errors, we do not wait up to
405
- * DB_HEALTH_CHECK_INTERVAL (default 60s) to notice. A sliding-window counter
406
- * trips a force-reconnect as soon as the failure rate crosses a threshold.
407
- *
408
- * Why this exists:
409
- * - postgres.js transparently drops dead sockets and opens new ones on the
410
- * next query. A single `SELECT 1` on the periodic interval can therefore
411
- * false-pass while user-facing queries keep hitting the remaining dead
412
- * sockets in the pool.
413
- * - This module observes real query errors and, when it sees a burst of
414
- * connection-level failures, calls triggerForceReconnect() which performs
415
- * the same atomic-swap rebuild as the health check.
416
- *
417
- * Configuration (env vars, hardcoded defaults):
418
- * - DB_RECONNECT_ERROR_THRESHOLD (default 3): errors needed in window
419
- * - DB_RECONNECT_ERROR_WINDOW_MS (default 10000): sliding window size
420
- */
421
400
  /**
422
401
  * Determine whether an error looks like a pool/connection failure
423
402
  *
@@ -1139,6 +1118,23 @@ type TransactionDB<TDatabase extends DrizzleDatabase = DefaultDatabase> = Databa
1139
1118
  * afterCommit callback type
1140
1119
  */
1141
1120
  type AfterCommitCallback = () => void | Promise<void>;
1121
+ /**
1122
+ * beforeCommit callback type
1123
+ */
1124
+ type BeforeCommitCallback = () => void | Promise<void>;
1125
+ /**
1126
+ * afterRollback callback type
1127
+ */
1128
+ type AfterRollbackCallback = () => void | Promise<void>;
1129
+ /**
1130
+ * Serializes the savepoint frames opened directly off one transaction context
1131
+ *
1132
+ * @see createNestedFrameGate
1133
+ */
1134
+ type NestedFrameGate = {
1135
+ /** Run `frame` once every frame queued before it on this context has finished */
1136
+ run<T>(frame: () => Promise<T>): Promise<T>;
1137
+ };
1142
1138
  /**
1143
1139
  * Transaction context stored in AsyncLocalStorage
1144
1140
  */
@@ -1148,8 +1144,14 @@ type TransactionContext<TDatabase extends DrizzleDatabase = DrizzleDatabase> = {
1148
1144
  /** Unique transaction ID for logging and tracing */
1149
1145
  txId: string;
1150
1146
  level: number;
1147
+ /** Callbacks to execute before the root transaction commits (still inside it) */
1148
+ beforeCommitCallbacks: BeforeCommitCallback[];
1151
1149
  /** Callbacks to execute after root transaction commits */
1152
1150
  afterCommitCallbacks: AfterCommitCallback[];
1151
+ /** Callbacks to execute after the root transaction rolled back */
1152
+ afterRollbackCallbacks: AfterRollbackCallback[];
1153
+ /** Serializes the savepoint frames opened directly off THIS context */
1154
+ nestedFrames: NestedFrameGate;
1153
1155
  };
1154
1156
  /**
1155
1157
  * Get current transaction object and metadata from AsyncLocalStorage
@@ -1187,7 +1189,7 @@ callback: () => Promise<T>): Promise<T>;
1187
1189
  *
1188
1190
  * @example
1189
1191
  * ```typescript
1190
- * import { onAfterCommit } from '@spfn/core/db/transaction';
1192
+ * import { onAfterCommit } from '@spfn/core/db';
1191
1193
  *
1192
1194
  * async function submit(spaceId: string, chatId: string)
1193
1195
  * {
@@ -1201,6 +1203,77 @@ callback: () => Promise<T>): Promise<T>;
1201
1203
  * ```
1202
1204
  */
1203
1205
  declare function onAfterCommit(callback: AfterCommitCallback): void;
1206
+ /**
1207
+ * Register a callback to run just before the current transaction commits
1208
+ *
1209
+ * - Inside a transaction: queued and executed after the root callback resolves,
1210
+ * while the transaction is still open — the callback MAY run statements and
1211
+ * they are part of the same commit
1212
+ * - Outside a transaction: executed immediately with a WARNING (same rationale as
1213
+ * onAfterCommit — there is nothing left to commit), and the abort semantics
1214
+ * below do not apply: there is no transaction for a throw to roll back
1215
+ * - Nested transactions: callbacks bubble up to root transaction
1216
+ * - Callbacks run in registration order, inside the transaction context
1217
+ * (getTransaction() returns the live tx)
1218
+ * - The queue is snapshot before the pass, so a callback registered BY a
1219
+ * beforeCommit callback does not run for this commit — it would otherwise
1220
+ * grow the queue mid-iteration and loop forever inside the open transaction
1221
+ * - A throw ABORTS: later callbacks are skipped, the transaction rolls back,
1222
+ * the error propagates to the caller, and afterRollback callbacks fire
1223
+ *
1224
+ * @example
1225
+ * ```typescript
1226
+ * import { runInTransaction, onBeforeCommit } from '@spfn/core/db';
1227
+ *
1228
+ * async function transfer(fromId: string, toId: string, amount: number)
1229
+ * {
1230
+ * // The transaction is what gives the check teeth — registered outside one,
1231
+ * // the callback runs immediately and its throw aborts nothing.
1232
+ * await runInTransaction(async () =>
1233
+ * {
1234
+ * await accountRepo.debit(fromId, amount);
1235
+ * await accountRepo.credit(toId, amount);
1236
+ *
1237
+ * // Last-moment invariant check: a throw here rolls the whole transfer back.
1238
+ * onBeforeCommit(() => assertNoNegativeBalance(fromId));
1239
+ * });
1240
+ * }
1241
+ * ```
1242
+ */
1243
+ declare function onBeforeCommit(callback: BeforeCommitCallback): void;
1244
+ /**
1245
+ * Register a callback to run after the current transaction rolled back
1246
+ *
1247
+ * - Inside a transaction: queued and executed after the ROOT transaction rolled
1248
+ * back, before the causing error leaves runInTransaction / the middleware
1249
+ * - Outside a transaction: no-op with a warning — there is no rollback to wait for
1250
+ * - Nested transactions: callbacks bubble up to root transaction. A nested
1251
+ * rollback that the root survives does NOT fire them; these hooks are about
1252
+ * the root transaction's fate, not a savepoint's
1253
+ * - Callbacks run outside the transaction context (new connection for DB access)
1254
+ * - Errors are logged but never thrown: the original error keeps propagating
1255
+ * unchanged, never replaced by a callback failure
1256
+ *
1257
+ * @example
1258
+ * ```typescript
1259
+ * import { runInTransaction, onAfterRollback } from '@spfn/core/db';
1260
+ *
1261
+ * async function importAvatar(userId: string, file: Blob)
1262
+ * {
1263
+ * // Upload first: external I/O never belongs inside the transaction.
1264
+ * const key = await objectStore.put(file);
1265
+ *
1266
+ * await runInTransaction(async () =>
1267
+ * {
1268
+ * await userRepo.updateAvatar(userId, key);
1269
+ *
1270
+ * // The upload cannot roll back on its own — undo it if the write never lands.
1271
+ * onAfterRollback(() => objectStore.delete(key));
1272
+ * });
1273
+ * }
1274
+ * ```
1275
+ */
1276
+ declare function onAfterRollback(callback: AfterRollbackCallback): void;
1204
1277
 
1205
1278
  /**
1206
1279
  * Transaction middleware options
@@ -1245,6 +1318,21 @@ interface TransactionalOptions {
1245
1318
  * @default 30000 (30s) or TRANSACTION_IDLE_TIMEOUT environment variable
1246
1319
  */
1247
1320
  idleTimeout?: number;
1321
+ /**
1322
+ * Run in an independent transaction instead of joining an ambient one.
1323
+ *
1324
+ * Only bites when the middleware itself runs nested — a sub-app mounted
1325
+ * under a route that already applied `Transactional()`, or a handler invoked
1326
+ * from inside `runInTransaction`. By default that inner run takes a SAVEPOINT
1327
+ * on the outer transaction; `requiresNew: true` gives it a real `BEGIN` on a
1328
+ * second pooled connection, with its own timeouts and its own hook queues.
1329
+ *
1330
+ * See `RunInTransactionOptions.requiresNew` for the pool and self-deadlock
1331
+ * costs — they apply here unchanged.
1332
+ *
1333
+ * @default false
1334
+ */
1335
+ requiresNew?: boolean;
1248
1336
  }
1249
1337
  /**
1250
1338
  * Transaction middleware for Hono routes
@@ -1282,6 +1370,9 @@ interface TransactionalOptions {
1282
1370
  * - Success: Auto-commit
1283
1371
  * - Error: Auto-rollback
1284
1372
  * - Detects context.error to trigger rollback
1373
+ * - Hooks: this delegates to runInTransaction, so onBeforeCommit, onAfterCommit
1374
+ * and onAfterRollback behave exactly as they do there. afterRollback callbacks
1375
+ * have already run by the time the error reaches the conversion below.
1285
1376
  *
1286
1377
  * 📊 Transaction logging:
1287
1378
  * - Auto-logs transaction start/commit/rollback
@@ -1317,8 +1408,12 @@ interface RunInTransactionOptions {
1317
1408
  * - `timeout: undefined` - Uses default (30s or TRANSACTION_TIMEOUT env var)
1318
1409
  * - `timeout: N` - Sets timeout to N milliseconds (1 to 2147483647)
1319
1410
  *
1320
- * Note: Timeout is only applied to root transactions. Nested transactions
1321
- * (SAVEPOINTs) inherit the timeout from the outer transaction.
1411
+ * Note: Timeout is only applied to root transactions. A nested call takes a
1412
+ * SAVEPOINT on the outer transaction's connection, where the outer
1413
+ * transaction's `SET LOCAL statement_timeout` is already in force — so the
1414
+ * nested call genuinely inherits it, and its own `timeout` is ignored (a
1415
+ * warning is logged when the caller passed one explicitly). A
1416
+ * `requiresNew: true` call is a root and gets its own.
1322
1417
  *
1323
1418
  * @default 30000 (30 seconds) or TRANSACTION_TIMEOUT environment variable
1324
1419
  *
@@ -1355,6 +1450,48 @@ interface RunInTransactionOptions {
1355
1450
  * @default 'transaction'
1356
1451
  */
1357
1452
  context?: string;
1453
+ /**
1454
+ * Run in an independent transaction instead of joining an ambient one.
1455
+ *
1456
+ * By default a call made while another transaction is open takes a SAVEPOINT
1457
+ * on that transaction's connection: its writes commit or roll back with the
1458
+ * outer transaction. `requiresNew: true` opens a real `BEGIN` on a SECOND
1459
+ * pooled connection instead, so the work commits on its own and survives an
1460
+ * outer rollback — an audit trail or a failed-attempt record, for example.
1461
+ *
1462
+ * Being a root transaction, it gets its own `statement_timeout`,
1463
+ * `idle_in_transaction_session_timeout`, and its OWN hook queues:
1464
+ * `onBeforeCommit` / `onAfterCommit` / `onAfterRollback` registered inside it
1465
+ * fire on ITS outcome, not the outer transaction's.
1466
+ *
1467
+ * Two costs, both consequences of the second connection:
1468
+ * - It holds a second connection for its whole duration, so it counts twice
1469
+ * against the pool. Keep it short and don't fan it out.
1470
+ * - It cannot see the outer transaction's uncommitted writes, and it BLOCKS
1471
+ * on any row the outer transaction has locked. Since the outer transaction
1472
+ * is waiting for this call to return, that block is a self-deadlock that
1473
+ * only `statement_timeout` breaks. Never touch rows the outer transaction
1474
+ * wrote.
1475
+ *
1476
+ * @default false
1477
+ *
1478
+ * @example
1479
+ * ```typescript
1480
+ * await runInTransaction(async () =>
1481
+ * {
1482
+ * await orderRepo.create(order);
1483
+ *
1484
+ * // Lands even if the order below rolls the outer transaction back.
1485
+ * await runInTransaction(
1486
+ * () => auditRepo.record('order.attempted', order.id),
1487
+ * { requiresNew: true },
1488
+ * );
1489
+ *
1490
+ * await inventoryRepo.reserve(order.items); // may throw
1491
+ * });
1492
+ * ```
1493
+ */
1494
+ requiresNew?: boolean;
1358
1495
  }
1359
1496
  /**
1360
1497
  * Run a callback function within a database transaction
@@ -1366,6 +1503,15 @@ interface RunInTransactionOptions {
1366
1503
  * - Warns about slow transactions
1367
1504
  * - Enforces timeout if configured
1368
1505
  *
1506
+ * Called with a transaction already open on the async call chain, it takes a
1507
+ * SAVEPOINT on that transaction rather than opening an independent one: the work
1508
+ * runs on the same connection, sees the outer transaction's uncommitted writes,
1509
+ * and commits with it. A throw that the caller catches unwinds to the SAVEPOINT
1510
+ * and leaves the outer transaction healthy; a throw that propagates rolls the
1511
+ * whole thing back. Nested calls off one transaction are serialized — see
1512
+ * `openTransaction` — so `Promise.all` over them runs them one at a time. Pass
1513
+ * `requiresNew: true` for an independent transaction on its own connection.
1514
+ *
1369
1515
  * Errors are propagated to the caller without modification.
1370
1516
  * Caller is responsible for error handling and conversion.
1371
1517
  *
@@ -1680,11 +1826,11 @@ declare function count<T extends PgTable>(table: T, where?: WhereObject<InferSel
1680
1826
  *
1681
1827
  * @example With Transactions
1682
1828
  * ```typescript
1683
- * import { runWithTransaction } from '@spfn/core/db';
1829
+ * import { runInTransaction } from '@spfn/core/db';
1684
1830
  *
1685
1831
  * const userRepo = new UserRepository();
1686
1832
  *
1687
- * await runWithTransaction(async () => {
1833
+ * await runInTransaction(async () => {
1688
1834
  * // Both db and readDb automatically use the transaction context
1689
1835
  * const user = await userRepo.create({ name: 'John' });
1690
1836
  * await userRepo.findById(user.id); // Uses same transaction
@@ -2006,4 +2152,4 @@ declare abstract class BaseRepository<TRelations extends AnyRelations = EmptyRel
2006
2152
  protected _count<T extends PgTable>(table: T, where?: Record<string, any> | SQL | undefined): Promise<number>;
2007
2153
  }
2008
2154
 
2009
- export { type AfterCommitCallback, BaseRepository, type DatabaseClients, type DatabaseInitOptions, type DatabaseOptions, type DatabaseProvider, type DatabaseTransaction, type DefaultDatabase, type DrizzleConfigOptions, type DrizzleDatabase, type FunctionMigrationEntry, type FunctionMigrationInfo, type MigrationStatus, type MigrationStatusDb, type MigrationTargetStatus, PROJECT_MIGRATIONS_TABLE, PROJECT_TARGET_NAME, type PoolConfig, RUN_MIGRATIONS_HINT, type RepositoryDatabase, RepositoryError, type RetryConfig, type RunInTransactionOptions, type TransactionContext, type TransactionDB, Transactional, type TransactionalOptions, auditFields, checkConnection, closeDatabase, collectMigrationStatus, count, countPendingMigrations, create, createDatabaseConnection, createDatabaseFromEnv, createMany, createSchema, deleteMany, deleteOne, detectDialect, discoverFunctionMigrations, enumText, filterPendingEntries, findMany, findOne, forceReconnectDatabase, foreignKey, formatPendingMigrations, fromPostgresError, functionMigrationsTable, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, getTransactionContext, hasMigrationTargets, id, initDatabase, isConnectionLevelError, migrationTargets, onAfterCommit, optionalForeignKey, packageNameToSchema, pendingMigrationTargets, pendingMigrationsSummary, projectMigrationsDir, publishingFields, readMigrationEntries, reportDatabaseError, resetConnectionErrorCounter, runInTransaction, runWithTransaction, setDatabase, setDatabaseProvider, softDelete, timestamps, typedJsonb, updateMany, updateOne, upsert, utcTimestamp, uuid, verificationTimestamp };
2155
+ export { type AfterCommitCallback, type AfterRollbackCallback, BaseRepository, type BeforeCommitCallback, type DatabaseClients, type DatabaseInitOptions, type DatabaseOptions, type DatabaseProvider, type DatabaseTransaction, type DefaultDatabase, type DrizzleConfigOptions, type DrizzleDatabase, type FunctionMigrationEntry, type FunctionMigrationInfo, type MigrationStatus, type MigrationStatusDb, type MigrationTargetStatus, type NestedFrameGate, PROJECT_MIGRATIONS_TABLE, PROJECT_TARGET_NAME, type PoolConfig, RUN_MIGRATIONS_HINT, type RepositoryDatabase, RepositoryError, type RetryConfig, type RunInTransactionOptions, type TransactionContext, type TransactionDB, Transactional, type TransactionalOptions, auditFields, checkConnection, closeDatabase, collectMigrationStatus, count, countPendingMigrations, create, createDatabaseConnection, createDatabaseFromEnv, createMany, createSchema, deleteMany, deleteOne, detectDialect, discoverFunctionMigrations, enumText, filterPendingEntries, findMany, findOne, forceReconnectDatabase, foreignKey, formatPendingMigrations, fromPostgresError, functionMigrationsTable, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, getTransactionContext, hasMigrationTargets, id, initDatabase, isConnectionLevelError, migrationTargets, onAfterCommit, onAfterRollback, onBeforeCommit, optionalForeignKey, packageNameToSchema, pendingMigrationTargets, pendingMigrationsSummary, projectMigrationsDir, publishingFields, readMigrationEntries, reportDatabaseError, resetConnectionErrorCounter, runInTransaction, runWithTransaction, setDatabase, setDatabaseProvider, softDelete, timestamps, typedJsonb, updateMany, updateOne, upsert, utcTimestamp, uuid, verificationTimestamp };