@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 +132 -4
- 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/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/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,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
|