@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.
Files changed (45) hide show
  1. package/README.md +183 -4
  2. package/dist/authz/index.js +1 -381
  3. package/dist/authz/index.js.map +1 -1
  4. package/dist/db/index.d.ts +173 -27
  5. package/dist/db/index.js +192 -57
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/env/loader.js +24 -1
  8. package/dist/env/loader.js.map +1 -1
  9. package/dist/errors/index.js +1 -381
  10. package/dist/errors/index.js.map +1 -1
  11. package/dist/logger/index.js +0 -12
  12. package/dist/logger/index.js.map +1 -1
  13. package/dist/middleware/index.js +6 -387
  14. package/dist/middleware/index.js.map +1 -1
  15. package/dist/nextjs/index.d.ts +18 -1
  16. package/dist/nextjs/index.js +40 -1
  17. package/dist/nextjs/index.js.map +1 -1
  18. package/dist/nextjs/server.d.ts +34 -1
  19. package/dist/nextjs/server.js +14 -0
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/ops/index.d.ts +61 -6
  22. package/dist/ops/index.js +330 -30
  23. package/dist/ops/index.js.map +1 -1
  24. package/dist/server/index.js +24 -1
  25. package/dist/server/index.js.map +1 -1
  26. package/docs/file-upload.md +195 -333
  27. package/package.json +6 -5
  28. package/src/cache/README.md +330 -0
  29. package/src/codegen/README.md +516 -0
  30. package/src/config/README.md +326 -0
  31. package/src/contract/README.md +326 -0
  32. package/src/db/README.md +589 -0
  33. package/src/db/manager/README.md +500 -0
  34. package/src/db/schema/README.md +344 -0
  35. package/src/db/transaction/README.md +822 -0
  36. package/src/env/README.md +651 -0
  37. package/src/errors/README.md +429 -0
  38. package/src/event/README.md +736 -0
  39. package/src/job/README.md +514 -0
  40. package/src/logger/README.md +321 -0
  41. package/src/middleware/README.md +634 -0
  42. package/src/nextjs/README.md +608 -0
  43. package/src/route/README.md +738 -0
  44. package/src/security/README.md +100 -0
  45. package/src/server/README.md +704 -0
@@ -0,0 +1,500 @@
1
+ # @spfn/core/db/manager — DB connection lifecycle, pool, health-check & reconnect
2
+
3
+ Global singleton database manager for PostgreSQL Drizzle drivers. It provides a built-in
4
+ postgres.js path (connection acquisition, pool config, Primary+Replica detection, periodic
5
+ health checks, and two-tier automatic recovery) plus an external provider boundary for
6
+ drivers such as PGlite.
7
+
8
+ ## Import paths
9
+
10
+ Everything is consumed through **`@spfn/core/db`**. There is **no** `@spfn/core/db/manager`
11
+ package subpath — `manager/index.ts` is an internal barrel; the public surface is
12
+ re-exported by `@spfn/core/db`.
13
+
14
+ ```typescript
15
+ import {
16
+ initDatabase,
17
+ getDatabase,
18
+ setDatabase,
19
+ setDatabaseProvider,
20
+ closeDatabase,
21
+ getDatabaseInfo,
22
+ forceReconnectDatabase,
23
+ createDatabaseFromEnv,
24
+ createDatabaseConnection,
25
+ checkConnection,
26
+ reportDatabaseError,
27
+ isConnectionLevelError,
28
+ resetConnectionErrorCounter,
29
+ } from '@spfn/core/db';
30
+
31
+ import type {
32
+ DatabaseClients, DatabaseProvider, DatabaseTransaction,
33
+ DrizzleDatabase, PoolConfig, RetryConfig,
34
+ } from '@spfn/core/db';
35
+ ```
36
+
37
+ > `getDatabaseMonitoringConfig` and `getDatabaseInfo` are the only debug/introspection
38
+ > helpers. **`getDatabaseMonitoringConfig` is exported from the `manager/` barrel but is
39
+ > NOT re-exported through `@spfn/core/db`** — it is effectively internal (consumed by the
40
+ > repository layer). Do not rely on importing it from `@spfn/core/db`.
41
+
42
+ ---
43
+
44
+ ## Public API (complete, via `@spfn/core/db`)
45
+
46
+ Lifecycle:
47
+
48
+ - `initDatabase(options?): Promise<{ write?, read? }>` — connect from env, or register and
49
+ test `options.provider`. Idempotent + concurrency-locked.
50
+ - `getDatabase<TDatabase>(type?): TDatabase` — get the singleton instance. **Throws** if
51
+ not initialized. `type` is `'read' | 'write'` (default `'write'`). The default remains
52
+ `PostgresJsDatabase`; pass the injected driver type for an external provider.
53
+ - `setDatabase(write, read?): void` — directly set instances (testing/manual). No connect,
54
+ no validation, no cleanup of previous instances. Consume a manually registered instance
55
+ with `getDatabase()`; `initDatabase()` rejects it until it is cleared or closed. It also
56
+ rejects writes during initialization/close or while managed connections or an external
57
+ provider are active.
58
+ - `setDatabaseProvider(provider)` — synchronously register an external provider without a
59
+ connection test. `initDatabase({ provider })` is preferred for application startup.
60
+ - `closeDatabase(): Promise<void>` — graceful shutdown: stop health check, end pools, clear
61
+ global state, or invoke the external provider's `close` callback exactly once.
62
+
63
+ Recovery:
64
+
65
+ - `forceReconnectDatabase(reason?): Promise<boolean>` — on-demand atomic-swap pool rebuild.
66
+ Returns `true` if a rebuild ran, `false` if skipped (uninitialized / closing / already
67
+ reconnecting).
68
+ - `reportDatabaseError(error): void` — feed a caught query error to the fast-path trigger.
69
+ No-op for non-connection errors. Fire-and-forget (never awaits, never throws).
70
+ - `isConnectionLevelError(error): boolean` — classify whether an error is connection-level
71
+ (vs. a query/constraint error).
72
+ - `resetConnectionErrorCounter(): void` — test helper; clears the sliding-window counter.
73
+
74
+ Low-level / factory:
75
+
76
+ - `createDatabaseFromEnv(options?): Promise<DatabaseClients>` — build clients from env using
77
+ pattern detection. Does **not** touch global state or start health checks.
78
+ - `createDatabaseConnection(connectionString, poolConfig, retryConfig): Promise<Sql>` —
79
+ single postgres.js client with exponential-backoff retry. Returns the raw `Sql` client.
80
+ - `checkConnection(client): Promise<boolean>` — run `SELECT 1`, return health as a boolean
81
+ (never throws).
82
+
83
+ Introspection:
84
+
85
+ - `getDatabaseInfo(): { hasWrite, hasRead, isReplica, providerKind? }` — non-throwing status snapshot.
86
+
87
+ Types: `DatabaseClients`, `DatabaseInitOptions`, `DatabaseOptions`, `DatabaseProvider`,
88
+ `DatabaseTransaction`, `DrizzleDatabase`, `DefaultDatabase`, `PoolConfig`, `RetryConfig`
89
+ (also `DbConnectionType`, `GetDatabaseFn` from the internal barrel).
90
+
91
+ > **Not exported (internal):** `detectDatabasePattern`, `startHealthCheck`,
92
+ > `stopHealthCheck`, `triggerForceReconnect`, `reconnectAndRestore`, all of
93
+ > `global-state.ts` (`getWriteInstance`/`setWriteInstance`/…), and the config builders
94
+ > (`getPoolConfig`, `getRetryConfig`, `buildHealthCheckConfig`, `buildMonitoringConfig`).
95
+ > Configure them via `initDatabase(options)` or env vars — do not import them.
96
+ > There is **no** `discoverPackageSchemas` export. Schema discovery is reached only through
97
+ > `getDrizzleConfig` / `generateDrizzleConfigFile` (from `@spfn/core/db`, defined in
98
+ > `config-generator.ts` — out of scope for this README).
99
+
100
+ ---
101
+
102
+ ## Quick Start
103
+
104
+ ```typescript
105
+ import { initDatabase, getDatabase, closeDatabase } from '@spfn/core/db';
106
+
107
+ await initDatabase(); // auto-detect from env, test, start health check
108
+
109
+ const db = getDatabase(); // 'write' (default)
110
+ const dbR = getDatabase('read'); // replica if configured, else falls back to write
111
+
112
+ const users = await db.select().from(usersTable);
113
+
114
+ process.on('SIGTERM', async () => {
115
+ await closeDatabase();
116
+ process.exit(0);
117
+ });
118
+ ```
119
+
120
+ ### Environment variables (connection)
121
+
122
+ ```bash
123
+ # Single database (most common)
124
+ DATABASE_URL=postgresql://localhost:5432/mydb
125
+
126
+ # Primary + Replica
127
+ DATABASE_WRITE_URL=postgresql://primary:5432/mydb
128
+ DATABASE_READ_URL=postgresql://replica:5432/mydb
129
+ ```
130
+
131
+ `DATABASE_URL`, `DATABASE_WRITE_URL`, `DATABASE_READ_URL` are validated via the
132
+ `@spfn/core/config` schema (`parsePostgresUrl`). All read through `env` from
133
+ `@spfn/core/config`, **not** raw `process.env`.
134
+
135
+ ---
136
+
137
+ ## Connection acquisition (`getDatabase`)
138
+
139
+ `getDatabase(type?)` reads the singleton off `globalThis`:
140
+
141
+ - `getDatabase()` / `getDatabase('write')` → write instance, or **throws**
142
+ `Database not initialized (type: write)…` if `initDatabase()` was never called.
143
+ - `getDatabase('read')` → read instance, **falling back to write** when no replica is
144
+ configured (`readInst ?? writeInst`). Throws only if neither exists.
145
+
146
+ It never returns `undefined`. There is **no implicit lazy init** — you must call
147
+ `initDatabase()` (or `setDatabase()`) first.
148
+
149
+ Set `DB_DEBUG_TRACE=true` (non-production only) to log every `getDatabase()` call with the
150
+ resolved caller `file:line` extracted from the stack — useful for tracing "who is querying
151
+ before init".
152
+
153
+ ### `initDatabase(options?)` semantics
154
+
155
+ - **Idempotent for the same source**: an environment-backed caller may reuse the active
156
+ environment-backed database, and a provider caller may reuse the exact same provider object.
157
+ Supplying a different provider (or omitting the active external provider) throws instead of
158
+ returning a database under the wrong driver type.
159
+ - **Concurrency-locked**: an in-flight `initDatabase()` is shared through a global lifecycle
160
+ lock (including across development module reloads); parallel callers for the same source
161
+ await the same promise. A different provider is rejected while initialization is in flight.
162
+ - **Tests connections** (`SELECT 1` on write, and on read if distinct) before marking ready;
163
+ on failure it cleans up the half-open clients and throws
164
+ `Database connection test failed: …`.
165
+ - **Persists `options`** to global state so `forceReconnectDatabase()` and health-check
166
+ recovery rebuild with the *same* pool/health/monitoring config.
167
+ - Throws `Cannot initialize database while closing` if called during `closeDatabase()`.
168
+
169
+ When `options.provider` is present, `initDatabase` tests its write/read Drizzle instances,
170
+ registers them, and skips environment-based postgres.js pool creation, periodic health
171
+ checks, and automatic reconnect. Provider implementations own their driver lifecycle;
172
+ SPFN calls their optional `close` callback during `closeDatabase()` and when provider
173
+ initialization fails after ownership has been handed to `initDatabase()`. To replace a
174
+ provider, await `closeDatabase()` before registering the next one.
175
+
176
+ ```typescript
177
+ await initDatabase({
178
+ pool: { max: 50, idleTimeout: 60 },
179
+ healthCheck: { enabled: true, interval: 30000, reconnect: true, maxRetries: 5, retryInterval: 5000 },
180
+ monitoring: { enabled: true, slowThreshold: 1000, logQueries: false },
181
+ });
182
+ ```
183
+
184
+ ### External provider (PGlite example)
185
+
186
+ PGlite remains a consumer dependency; `@spfn/core` does not load it at runtime.
187
+
188
+ ```typescript
189
+ import { PGlite } from '@electric-sql/pglite';
190
+ import { defineRelations } from 'drizzle-orm';
191
+ import { drizzle, type PgliteDatabase } from 'drizzle-orm/pglite';
192
+ import {
193
+ BaseRepository,
194
+ getDatabase,
195
+ initDatabase,
196
+ runInTransaction,
197
+ } from '@spfn/core/db';
198
+
199
+ const client = await PGlite.create('file://./data/app');
200
+ const relations = defineRelations(schema);
201
+ const db = drizzle({ client, schema: relations });
202
+
203
+ await initDatabase({
204
+ provider: {
205
+ kind: 'pglite',
206
+ write: db,
207
+ close: () => client.close(),
208
+ },
209
+ });
210
+
211
+ type AppDatabase = PgliteDatabase<typeof relations>;
212
+
213
+ const sameDb = getDatabase<AppDatabase>();
214
+
215
+ class ProjectRepository extends BaseRepository<typeof relations, AppDatabase>
216
+ {
217
+ // this.db / this.readDb preserve AppDatabase
218
+ }
219
+
220
+ await runInTransaction<void, AppDatabase>(async (tx) =>
221
+ {
222
+ await tx.insert(schema.projects).values({ id: 'project-1' });
223
+ });
224
+ ```
225
+
226
+ Server configs use the same provider through
227
+ `defineServerConfig().database({ provider }).build()`. `startServer()` registers it instead
228
+ of creating a postgres.js pool, and graceful shutdown calls `provider.close`.
229
+
230
+ ---
231
+
232
+ ## Pattern detection (factory)
233
+
234
+ `createDatabaseFromEnv()` detects, in priority order:
235
+
236
+ 1. **write-read** — `DATABASE_WRITE_URL` **and** `DATABASE_READ_URL` set → separate pools.
237
+ 2. **single** — `DATABASE_URL` set → one pool used for both read and write.
238
+ 3. **single** — only `DATABASE_WRITE_URL` set → write-only, one pool.
239
+ 4. **none** — nothing set → throws `No database configuration found…`.
240
+
241
+ In the **write-read** case the write connection is required (failure throws
242
+ `Write database connection failed: …`); the read connection is **optional** — if the replica
243
+ fails to connect, it logs a warning and falls back to the write client for reads (so
244
+ `isReplica` becomes `false`).
245
+
246
+ > `detectDatabasePattern()` is an internal function, not an export. Use `getDatabaseInfo()`
247
+ > (`{ hasWrite, hasRead, isReplica }`) to inspect the resolved topology.
248
+
249
+ ---
250
+
251
+ ## Pool & retry config
252
+
253
+ Resolution priority for every knob: **`options` arg > env var > NODE_ENV default**.
254
+ Pool and retry numbers are parsed from **raw `process.env`** inside `config.ts`
255
+ (`parseEnvNumber`/`parseEnvBoolean`), independent of the `@spfn/core/config` schema.
256
+
257
+ ### Pool
258
+
259
+ ```bash
260
+ DB_POOL_MAX=20 # max connections (prod default 20, dev 10)
261
+ DB_POOL_IDLE_TIMEOUT=30 # idle timeout (s) (prod default 30, dev 20)
262
+ ```
263
+
264
+ ### Retry (exponential backoff, applied per `createDatabaseConnection`)
265
+
266
+ ```bash
267
+ DB_RETRY_MAX=5 # max attempts (prod 5, dev 3)
268
+ DB_RETRY_INITIAL_DELAY=100 # ms (prod 100, dev 50)
269
+ DB_RETRY_MAX_DELAY=10000 # ms cap (prod 10000, dev 5000)
270
+ DB_RETRY_FACTOR=2 # multiplier (prod 2, dev 2)
271
+ ```
272
+
273
+ Backoff = `min(initialDelay * factor^attempt, maxDelay)` with 50–100% jitter.
274
+ **Non-retryable errors fail immediately** (no retry): authentication failures, "database
275
+ does not exist", and SSL/TLS errors. Total attempts = `maxRetries + 1`.
276
+
277
+ ### Connect timeout & socket family
278
+
279
+ - `connect_timeout` is fixed at **10s** per attempt (postgres.js handles it; no
280
+ `Promise.race`).
281
+ - `DATABASE_SOCKET_FAMILY=4` or `=6` forces IPv4/IPv6 for DB sockets — a workaround for
282
+ Node 25+ Happy Eyeballs causing `EHOSTUNREACH`. Read from raw `process.env`; unset =
283
+ default Node behavior.
284
+
285
+ ---
286
+
287
+ ## Health check & two-tier recovery
288
+
289
+ When a Postgres server restarts, a partition heals, or a deploy rotates the DB, the whole
290
+ postgres.js pool can hold dead sockets. Recovery happens two ways, both using the **same
291
+ atomic swap**: a fresh pool is created **and validated** before the global reference is
292
+ replaced, then the old clients are `end({ timeout: 5 })`-ed.
293
+
294
+ ### 1. Periodic health check (interval-driven)
295
+
296
+ Started automatically by `initDatabase()` when `healthCheck.enabled`. Every
297
+ `DB_HEALTH_CHECK_INTERVAL` (default **60000ms**) it runs `SELECT 1` on write (and read if
298
+ distinct). On failure, if `reconnect` is on, it runs `attemptReconnection()`.
299
+
300
+ ```bash
301
+ DB_HEALTH_CHECK_ENABLED=true
302
+ DB_HEALTH_CHECK_INTERVAL=60000
303
+ DB_HEALTH_CHECK_RECONNECT=true
304
+ DB_HEALTH_CHECK_MAX_RETRIES=3
305
+ DB_HEALTH_CHECK_RETRY_INTERVAL=5000
306
+ ```
307
+
308
+ The interval is **never cleared during reconnection** — only `closeDatabase()` →
309
+ `stopHealthCheck()` clears it.
310
+
311
+ ### 2. Query-error fast-path (error-driven)
312
+
313
+ A bare `SELECT 1` can false-pass (postgres.js opens a new socket for it while other dead
314
+ sockets remain). `reconnect-trigger.ts` watches **real** query errors instead. A
315
+ sliding-window counter trips a force-reconnect once enough connection-level failures occur,
316
+ cutting latency from up to 60s to a few seconds.
317
+
318
+ ```bash
319
+ DB_RECONNECT_ERROR_THRESHOLD=3 # connection errors needed to trip (read once at module load)
320
+ DB_RECONNECT_ERROR_WINDOW_MS=10000 # sliding window (min 1000ms)
321
+ ```
322
+
323
+ These two knobs are read **once at import time** (operational tuning, not per-call). Invalid
324
+ values silently fall back to defaults.
325
+
326
+ **Auto-hooked** — application code does **not** call `reportDatabaseError()` manually:
327
+
328
+ - `BaseRepository.withContext` (`src/db/repository.ts`) reports caught errors.
329
+ - The `@Transactional` middleware (`src/db/transaction/middleware.ts`) reports caught errors.
330
+
331
+ `isConnectionLevelError()` classifies across the whole error chain
332
+ (`cause`/`original`/`error`/`err`/`inner`):
333
+
334
+ - `instanceof ConnectionError`
335
+ - postgres.js codes: `CONNECTION_ENDED`, `CONNECTION_CLOSED`, `CONNECTION_DESTROYED`,
336
+ `CONNECT_TIMEOUT`, `CONNECTION_CONNECT_TIMEOUT`
337
+ - Node errno: `ECONNRESET`, `ECONNREFUSED`, `EPIPE`, `ETIMEDOUT`, `EHOSTUNREACH`,
338
+ `ENETUNREACH`, `ENOTFOUND`
339
+ - PG SQLSTATE: class `08*`, `53300`, `57P01/02/03`
340
+
341
+ ### Manual trigger
342
+
343
+ ```typescript
344
+ import { forceReconnectDatabase } from '@spfn/core/db';
345
+
346
+ app.post('/admin/db/reconnect', async (c) => {
347
+ const ran = await forceReconnectDatabase('admin_request');
348
+ return c.json({ reconnected: ran });
349
+ });
350
+ ```
351
+
352
+ Returns `false` (no rebuild) when the DB is uninitialized, currently closing, or a reconnect
353
+ is already in flight; `true` when a rebuild ran (success or retries exhausted).
354
+
355
+ ### Monitoring config
356
+
357
+ ```bash
358
+ DB_MONITORING_ENABLED=true # default: true in dev, false in prod
359
+ DB_MONITORING_SLOW_THRESHOLD=1000
360
+ DB_MONITORING_LOG_QUERIES=false
361
+ ```
362
+
363
+ Stored at init; consumed internally by the repository layer for slow-query logging.
364
+
365
+ ---
366
+
367
+ ## Pitfalls & anti-patterns
368
+
369
+ - **No `@spfn/core/db/manager` subpath.** Import from `@spfn/core/db`. The `manager/`
370
+ barrel is internal.
371
+ - **`getDatabase()` throws before init.** It does not lazily connect. Call `initDatabase()`
372
+ (server startup does this) or `setDatabase()` (tests) first, otherwise
373
+ `Database not initialized (type: …)`.
374
+ - **`getDatabase('read')` silently falls back to write** when no replica exists — it does
375
+ not throw, so a misconfigured `DATABASE_READ_URL` looks "fine". Check `getDatabaseInfo()
376
+ .isReplica` to confirm a real replica.
377
+ - **A failed replica connection is non-fatal.** In write-read mode a dead `DATABASE_READ_URL`
378
+ only logs a warning and reuses the write pool. Reads silently hit the primary.
379
+ - **Do not import the internals.** `detectDatabasePattern`, `getPoolConfig`,
380
+ `buildHealthCheckConfig`, `startHealthCheck`, `triggerForceReconnect`, and the
381
+ `global-state` accessors are not exported. Configure via `initDatabase(options)` / env.
382
+ - **`setDatabase()` does not clean up.** It swaps the global reference without closing the
383
+ previous pool — passing `undefined` leaks connections. Use `closeDatabase()` for real
384
+ teardown.
385
+ - **`setDatabaseProvider()` also does not replace-and-close.** It is a synchronous manual
386
+ registration API. Register once, then use `closeDatabase()` for teardown; use
387
+ `initDatabase({ provider })` when connection validation is wanted.
388
+ - **External providers do not use postgres.js recovery.** Health-check pool rebuilds and
389
+ `forceReconnectDatabase()` apply only to the built-in environment-backed postgres.js
390
+ path. A provider must implement any driver-specific recovery itself.
391
+ - **Don't manually call `reportDatabaseError()` for repo/transactional queries** — they are
392
+ already hooked. Manual calls there get **deduped** anyway (WeakSet across the error chain),
393
+ so a single failure counts once. Only feed it for raw `db.execute(...)` outside those
394
+ paths.
395
+ - **`reportDatabaseError()` is fire-and-forget** — do not `await` it. It never throws and
396
+ triggers the rebuild in the background.
397
+ - **Reconnect tuning is load-time only.** `DB_RECONNECT_ERROR_THRESHOLD` /
398
+ `DB_RECONNECT_ERROR_WINDOW_MS` are read once at import; changing `process.env` at runtime
399
+ has no effect.
400
+ - **Pool/retry env vars bypass the config schema.** `config.ts` reads them from raw
401
+ `process.env` (with `0`-min integer parsing), so they are NOT in the
402
+ `@spfn/core/config` env schema and won't show up in `spfn env validate`.
403
+ - **Closing wins over reconnect.** Reconnect paths re-check `isClosing` before the swap; if
404
+ `closeDatabase()` started mid-rebuild the fresh pool is torn down instead of leaked. Don't
405
+ race `closeDatabase()` with `forceReconnectDatabase()` expecting the rebuild to survive.
406
+ - **`isReplica` flips after a fallback.** If the replica was up at init but the pool later
407
+ rebuilds without it (or vice versa), topology can change — read it live, don't cache.
408
+
409
+ ---
410
+
411
+ ## Complete example
412
+
413
+ ```typescript
414
+ // src/server/db.ts
415
+ import { initDatabase, getDatabase, getDatabaseInfo, closeDatabase } from '@spfn/core/db';
416
+
417
+ export async function startDb()
418
+ {
419
+ await initDatabase({
420
+ pool: { max: 30, idleTimeout: 30 },
421
+ healthCheck: { enabled: true, interval: 30000, reconnect: true, maxRetries: 5, retryInterval: 5000 },
422
+ monitoring: { enabled: true, slowThreshold: 500 },
423
+ });
424
+
425
+ const info = getDatabaseInfo();
426
+ if (info.isReplica)
427
+ {
428
+ console.log('Primary + Replica active');
429
+ }
430
+ }
431
+
432
+ export function db() { return getDatabase('write'); }
433
+ export function dbRead() { return getDatabase('read'); }
434
+
435
+ process.on('SIGTERM', async () =>
436
+ {
437
+ await closeDatabase();
438
+ process.exit(0);
439
+ });
440
+ ```
441
+
442
+ ```typescript
443
+ // Raw query outside BaseRepository / @Transactional — opt into the fast-path manually:
444
+ import { getDatabase, reportDatabaseError } from '@spfn/core/db';
445
+ import { sql } from 'drizzle-orm';
446
+
447
+ try
448
+ {
449
+ await getDatabase().execute(sql`SELECT now()`);
450
+ }
451
+ catch (error)
452
+ {
453
+ reportDatabaseError(error); // fire-and-forget; no-op for non-connection errors
454
+ throw error;
455
+ }
456
+ ```
457
+
458
+ ---
459
+
460
+ ## Types reference
461
+
462
+ ```typescript
463
+ type DbConnectionType = 'read' | 'write';
464
+
465
+ type DrizzleDatabase = PgAsyncDatabase<any, any>;
466
+
467
+ interface DatabaseProvider<TDatabase extends DrizzleDatabase> {
468
+ write: TDatabase;
469
+ read?: TDatabase;
470
+ kind: string;
471
+ close?: () => void | Promise<void>;
472
+ }
473
+
474
+ interface DatabaseClients<TDatabase = PostgresJsDatabase> {
475
+ write?: TDatabase; // primary (or both if no replica)
476
+ read?: TDatabase; // replica (falls back to write)
477
+ writeClient?: Sql; // raw client, for cleanup
478
+ readClient?: Sql;
479
+ }
480
+
481
+ interface PoolConfig { max: number; idleTimeout: number; } // seconds
482
+ interface RetryConfig { maxRetries: number; initialDelay: number; maxDelay: number; factor: number; }
483
+
484
+ interface DatabaseOptions { // postgres.js pool/health/monitoring options
485
+ pool?: Partial<PoolConfig>;
486
+ healthCheck?: Partial<HealthCheckConfig>;
487
+ monitoring?: Partial<MonitoringConfig>;
488
+ }
489
+
490
+ interface DatabaseInitOptions<TDatabase> extends DatabaseOptions {
491
+ provider?: DatabaseProvider<TDatabase>;
492
+ }
493
+ ```
494
+
495
+ ## Related
496
+
497
+ - [@spfn/core/db](../README.md) — DB module (helpers, schema, transaction, repository)
498
+ - [@spfn/core/config](../../config/README.md) — env schema for `DATABASE_*` / `DB_*`
499
+ - [@spfn/core/env](../../env/README.md) — env parsing/validation primitives
500
+ - [@spfn/core/logger](../../logger/README.md) — structured logging