@spfn/core/db/manager
Global singleton database manager for postgres.js + Drizzle: connection acquisition, pool config, Primary+Replica detection, periodic health checks, and two-tier automatic pool recovery (periodic + query-error fast-path).
Import paths
Everything is consumed through @spfn/core/db. There is no @spfn/core/db/manager
package subpath — manager/index.ts is an internal barrel; the public surface is
re-exported by @spfn/core/db.
import {
initDatabase,
getDatabase,
setDatabase,
closeDatabase,
getDatabaseInfo,
forceReconnectDatabase,
createDatabaseFromEnv,
createDatabaseConnection,
checkConnection,
reportDatabaseError,
isConnectionLevelError,
resetConnectionErrorCounter,
} from '@spfn/core/db';
import type { DatabaseClients, PoolConfig, RetryConfig } from '@spfn/core/db';
getDatabaseMonitoringConfigandgetDatabaseInfoare the only debug/introspection helpers.getDatabaseMonitoringConfigis exported from themanager/barrel but is NOT re-exported through@spfn/core/db— it is effectively internal (consumed by the repository layer). Do not rely on importing it from@spfn/core/db.
Public API (complete, via @spfn/core/db)
Lifecycle:
initDatabase(options?): Promise<{ write?, read? }>— connect from env, test, start health check. Idempotent + concurrency-locked.getDatabase(type?): PostgresJsDatabase— get the singleton instance. Throws if not initialized.typeis'read' | 'write'(default'write').setDatabase(write, read?): void— directly set instances (testing/manual). No connect, no validation, no cleanup of previous instances.closeDatabase(): Promise<void>— graceful shutdown: stop health check, end pools, clear global state.
Recovery:
forceReconnectDatabase(reason?): Promise<boolean>— on-demand atomic-swap pool rebuild. Returnstrueif a rebuild ran,falseif skipped (uninitialized / closing / already reconnecting).reportDatabaseError(error): void— feed a caught query error to the fast-path trigger. No-op for non-connection errors. Fire-and-forget (never awaits, never throws).isConnectionLevelError(error): boolean— classify whether an error is connection-level (vs. a query/constraint error).resetConnectionErrorCounter(): void— test helper; clears the sliding-window counter.
Low-level / factory:
createDatabaseFromEnv(options?): Promise<DatabaseClients>— build clients from env using pattern detection. Does not touch global state or start health checks.createDatabaseConnection(connectionString, poolConfig, retryConfig): Promise<Sql>— single postgres.js client with exponential-backoff retry. Returns the rawSqlclient.checkConnection(client): Promise<boolean>— runSELECT 1, return health as a boolean (never throws).
Introspection:
getDatabaseInfo(): { hasWrite, hasRead, isReplica }— non-throwing status snapshot.
Types: DatabaseClients, PoolConfig, RetryConfig (also DbConnectionType,
GetDatabaseFn from the barrel, but those are not re-exported by @spfn/core/db).
Not exported (internal):
detectDatabasePattern,startHealthCheck,stopHealthCheck,triggerForceReconnect,reconnectAndRestore, all ofglobal-state.ts(getWriteInstance/setWriteInstance/…), and the config builders (getPoolConfig,getRetryConfig,buildHealthCheckConfig,buildMonitoringConfig). Configure them viainitDatabase(options)or env vars — do not import them. There is nodiscoverPackageSchemasexport. Schema discovery is reached only throughgetDrizzleConfig/generateDrizzleConfigFile(from@spfn/core/db, defined inconfig-generator.ts— out of scope for this README).
Quick Start
import { initDatabase, getDatabase, closeDatabase } from '@spfn/core/db';
await initDatabase(); // auto-detect from env, test, start health check
const db = getDatabase(); // 'write' (default)
const dbR = getDatabase('read'); // replica if configured, else falls back to write
const users = await db.select().from(usersTable);
process.on('SIGTERM', async () => {
await closeDatabase();
process.exit(0);
});
Environment variables (connection)
# Single database (most common)
DATABASE_URL=postgresql://localhost:5432/mydb
# Primary + Replica
DATABASE_WRITE_URL=postgresql://primary:5432/mydb
DATABASE_READ_URL=postgresql://replica:5432/mydb
DATABASE_URL, DATABASE_WRITE_URL, DATABASE_READ_URL are validated via the
@spfn/core/config schema (parsePostgresUrl). All read through env from
@spfn/core/config, not raw process.env.
Connection acquisition (getDatabase)
getDatabase(type?) reads the singleton off globalThis:
getDatabase()/getDatabase('write')→ write instance, or throwsDatabase not initialized (type: write)…ifinitDatabase()was never called.getDatabase('read')→ read instance, falling back to write when no replica is configured (readInst ?? writeInst). Throws only if neither exists.
It never returns undefined. There is no implicit lazy init — you must call
initDatabase() (or setDatabase()) first.
Set DB_DEBUG_TRACE=true (non-production only) to log every getDatabase() call with the
resolved caller file:line extracted from the stack — useful for tracing "who is querying
before init".
initDatabase(options?) semantics
- Idempotent: if a write instance already exists, returns it immediately.
- Concurrency-locked: an in-flight
initDatabase()is shared via an internalinitPromise; parallel callers await the same promise. - Tests connections (
SELECT 1on write, and on read if distinct) before marking ready; on failure it cleans up the half-open clients and throwsDatabase connection test failed: …. - Persists
optionsto global state soforceReconnectDatabase()and health-check recovery rebuild with the same pool/health/monitoring config. - Throws
Cannot initialize database while closingif called duringcloseDatabase().
await initDatabase({
pool: { max: 50, idleTimeout: 60 },
healthCheck: { enabled: true, interval: 30000, reconnect: true, maxRetries: 5, retryInterval: 5000 },
monitoring: { enabled: true, slowThreshold: 1000, logQueries: false },
});
Pattern detection (factory)
createDatabaseFromEnv() detects, in priority order:
- write-read —
DATABASE_WRITE_URLandDATABASE_READ_URLset → separate pools. - single —
DATABASE_URLset → one pool used for both read and write. - single — only
DATABASE_WRITE_URLset → write-only, one pool. - none — nothing set → throws
No database configuration found….
In the write-read case the write connection is required (failure throws
Write database connection failed: …); the read connection is optional — if the replica
fails to connect, it logs a warning and falls back to the write client for reads (so
isReplica becomes false).
detectDatabasePattern()is an internal function, not an export. UsegetDatabaseInfo()({ hasWrite, hasRead, isReplica }) to inspect the resolved topology.
Pool & retry config
Resolution priority for every knob: options arg > env var > NODE_ENV default.
Pool and retry numbers are parsed from raw process.env inside config.ts
(parseEnvNumber/parseEnvBoolean), independent of the @spfn/core/config schema.
Pool
DB_POOL_MAX=20 # max connections (prod default 20, dev 10)
DB_POOL_IDLE_TIMEOUT=30 # idle timeout (s) (prod default 30, dev 20)
Retry (exponential backoff, applied per createDatabaseConnection)
DB_RETRY_MAX=5 # max attempts (prod 5, dev 3)
DB_RETRY_INITIAL_DELAY=100 # ms (prod 100, dev 50)
DB_RETRY_MAX_DELAY=10000 # ms cap (prod 10000, dev 5000)
DB_RETRY_FACTOR=2 # multiplier (prod 2, dev 2)
Backoff = min(initialDelay * factor^attempt, maxDelay) with 50–100% jitter.
Non-retryable errors fail immediately (no retry): authentication failures, "database
does not exist", and SSL/TLS errors. Total attempts = maxRetries + 1.
Connect timeout & socket family
connect_timeoutis fixed at 10s per attempt (postgres.js handles it; noPromise.race).DATABASE_SOCKET_FAMILY=4or=6forces IPv4/IPv6 for DB sockets — a workaround for Node 25+ Happy Eyeballs causingEHOSTUNREACH. Read from rawprocess.env; unset = default Node behavior.
Health check & two-tier recovery
When a Postgres server restarts, a partition heals, or a deploy rotates the DB, the whole
postgres.js pool can hold dead sockets. Recovery happens two ways, both using the same
atomic swap: a fresh pool is created and validated before the global reference is
replaced, then the old clients are end({ timeout: 5 })-ed.
1. Periodic health check (interval-driven)
Started automatically by initDatabase() when healthCheck.enabled. Every
DB_HEALTH_CHECK_INTERVAL (default 60000ms) it runs SELECT 1 on write (and read if
distinct). On failure, if reconnect is on, it runs attemptReconnection().
DB_HEALTH_CHECK_ENABLED=true
DB_HEALTH_CHECK_INTERVAL=60000
DB_HEALTH_CHECK_RECONNECT=true
DB_HEALTH_CHECK_MAX_RETRIES=3
DB_HEALTH_CHECK_RETRY_INTERVAL=5000
The interval is never cleared during reconnection — only closeDatabase() →
stopHealthCheck() clears it.
2. Query-error fast-path (error-driven)
A bare SELECT 1 can false-pass (postgres.js opens a new socket for it while other dead
sockets remain). reconnect-trigger.ts watches real query errors instead. A
sliding-window counter trips a force-reconnect once enough connection-level failures occur,
cutting latency from up to 60s to a few seconds.
DB_RECONNECT_ERROR_THRESHOLD=3 # connection errors needed to trip (read once at module load)
DB_RECONNECT_ERROR_WINDOW_MS=10000 # sliding window (min 1000ms)
These two knobs are read once at import time (operational tuning, not per-call). Invalid values silently fall back to defaults.
Auto-hooked — application code does not call reportDatabaseError() manually:
BaseRepository.withContext(src/db/repository.ts) reports caught errors.- The
@Transactionalmiddleware (src/db/transaction/middleware.ts) reports caught errors.
isConnectionLevelError() classifies across the whole error chain
(cause/original/error/err/inner):
instanceof ConnectionError- postgres.js codes:
CONNECTION_ENDED,CONNECTION_CLOSED,CONNECTION_DESTROYED,CONNECT_TIMEOUT,CONNECTION_CONNECT_TIMEOUT - Node errno:
ECONNRESET,ECONNREFUSED,EPIPE,ETIMEDOUT,EHOSTUNREACH,ENETUNREACH,ENOTFOUND - PG SQLSTATE: class
08*,53300,57P01/02/03
Manual trigger
import { forceReconnectDatabase } from '@spfn/core/db';
app.post('/admin/db/reconnect', async (c) => {
const ran = await forceReconnectDatabase('admin_request');
return c.json({ reconnected: ran });
});
Returns false (no rebuild) when the DB is uninitialized, currently closing, or a reconnect
is already in flight; true when a rebuild ran (success or retries exhausted).
Monitoring config
DB_MONITORING_ENABLED=true # default: true in dev, false in prod
DB_MONITORING_SLOW_THRESHOLD=1000
DB_MONITORING_LOG_QUERIES=false
Stored at init; consumed internally by the repository layer for slow-query logging.
Pitfalls & anti-patterns
- No
@spfn/core/db/managersubpath. Import from@spfn/core/db. Themanager/barrel is internal. getDatabase()throws before init. It does not lazily connect. CallinitDatabase()(server startup does this) orsetDatabase()(tests) first, otherwiseDatabase not initialized (type: …).getDatabase('read')silently falls back to write when no replica exists — it does not throw, so a misconfiguredDATABASE_READ_URLlooks "fine". CheckgetDatabaseInfo() .isReplicato confirm a real replica.- A failed replica connection is non-fatal. In write-read mode a dead
DATABASE_READ_URLonly logs a warning and reuses the write pool. Reads silently hit the primary. - Do not import the internals.
detectDatabasePattern,getPoolConfig,buildHealthCheckConfig,startHealthCheck,triggerForceReconnect, and theglobal-stateaccessors are not exported. Configure viainitDatabase(options)/ env. setDatabase()does not clean up. It swaps the global reference without closing the previous pool — passingundefinedleaks connections. UsecloseDatabase()for real teardown.- Don't manually call
reportDatabaseError()for repo/transactional queries — they are already hooked. Manual calls there get deduped anyway (WeakSet across the error chain), so a single failure counts once. Only feed it for rawdb.execute(...)outside those paths. reportDatabaseError()is fire-and-forget — do notawaitit. It never throws and triggers the rebuild in the background.- Reconnect tuning is load-time only.
DB_RECONNECT_ERROR_THRESHOLD/DB_RECONNECT_ERROR_WINDOW_MSare read once at import; changingprocess.envat runtime has no effect. - Pool/retry env vars bypass the config schema.
config.tsreads them from rawprocess.env(with0-min integer parsing), so they are NOT in the@spfn/core/configenv schema and won't show up inspfn env validate. - Closing wins over reconnect. Reconnect paths re-check
isClosingbefore the swap; ifcloseDatabase()started mid-rebuild the fresh pool is torn down instead of leaked. Don't racecloseDatabase()withforceReconnectDatabase()expecting the rebuild to survive. isReplicaflips after a fallback. If the replica was up at init but the pool later rebuilds without it (or vice versa), topology can change — read it live, don't cache.
Complete example
// src/server/db.ts
import { initDatabase, getDatabase, getDatabaseInfo, closeDatabase } from '@spfn/core/db';
export async function startDb()
{
await initDatabase({
pool: { max: 30, idleTimeout: 30 },
healthCheck: { enabled: true, interval: 30000, reconnect: true, maxRetries: 5, retryInterval: 5000 },
monitoring: { enabled: true, slowThreshold: 500 },
});
const info = getDatabaseInfo();
if (info.isReplica)
{
console.log('Primary + Replica active');
}
}
export function db() { return getDatabase('write'); }
export function dbRead() { return getDatabase('read'); }
process.on('SIGTERM', async () =>
{
await closeDatabase();
process.exit(0);
});
// Raw query outside BaseRepository / @Transactional — opt into the fast-path manually:
import { getDatabase, reportDatabaseError } from '@spfn/core/db';
import { sql } from 'drizzle-orm';
try
{
await getDatabase().execute(sql`SELECT now()`);
}
catch (error)
{
reportDatabaseError(error); // fire-and-forget; no-op for non-connection errors
throw error;
}
Types reference
type DbConnectionType = 'read' | 'write';
interface DatabaseClients {
write?: PostgresJsDatabase; // primary (or both if no replica)
read?: PostgresJsDatabase; // replica (falls back to write)
writeClient?: Sql; // raw client, for cleanup
readClient?: Sql;
}
interface PoolConfig { max: number; idleTimeout: number; } // seconds
interface RetryConfig { maxRetries: number; initialDelay: number; maxDelay: number; factor: number; }
interface DatabaseOptions { // initDatabase() argument
pool?: Partial<PoolConfig>;
healthCheck?: Partial<HealthCheckConfig>;
monitoring?: Partial<MonitoringConfig>;
}
Related
- @spfn/core/db — DB module (helpers, schema, transaction, repository)
- @spfn/core/config — env schema for
DATABASE_*/DB_* - @spfn/core/env — env parsing/validation primitives
- @spfn/core/logger — structured logging