import { EventEmitter } from 'node:events'; import pg from 'pg'; import type { PoolConfig, PoolStats, CreatePoolOptions } from './types.ts'; import { ConnectionClosedError, QueryTimeoutError, isRetryableError } from './errors.ts'; import { registerClient, unregisterClient, type Registrable } from './registry.ts'; import { computePoolHotReloadKey, getSharedHotReloadPool, isHotReloadEnabled, } from './hot-reload.ts'; /** * A resilient PostgreSQL connection pool. * * Wraps the `pg` package's Pool and adds: * - Safe idle-client error handling (absorbed; pg.Pool self-heals) * - Query/connect retry on transient connection errors * - Connection state tracking * - Pool statistics * * @example * ```typescript * const pool = new PostgresPool({ * connectionString: process.env.DATABASE_URL, * max: 20, * reconnect: { maxAttempts: 5 } * }); * * // Execute queries * const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]); * * // Close when done * await pool.close(); * ``` */ export class PostgresPool extends EventEmitter implements Registrable { private _pool: pg.Pool | null = null; private _config: PoolConfig; private _poolConfig: pg.PoolConfig = {}; private _connected = false; private _reconnecting = false; private _closed = false; private _shuttingDown = false; private _signalHandlers: { signal: string; handler: () => void }[] = []; private _reconnectPromise: Promise | null = null; private _connectPromise: Promise | null = null; private _stats: Omit< PoolStats, 'connected' | 'reconnecting' | 'totalCount' | 'idleCount' | 'waitingCount' > = { totalConnections: 0, reconnectAttempts: 0, failedReconnects: 0, lastConnectedAt: null, lastDisconnectedAt: null, lastReconnectAttemptAt: null, }; /** * Creates a new PostgresPool. * * Note: By default, the actual connection is established lazily on first query. * Set `preconnect: true` in config to verify connectivity immediately. * * @param config - Connection configuration. Can be a connection URL string or a config object. * If not provided, uses `process.env.DATABASE_URL`. */ constructor(config?: string | PoolConfig) { super(); if (typeof config === 'string') { this._config = { connectionString: config }; } else { this._config = config ?? {}; } // Initialize the pool this._initializePool(); // Register shutdown signal handlers to prevent reconnection during app shutdown this._registerShutdownHandlers(); // Register this pool in the global registry for coordinated shutdown registerClient(this, { hotReloadKey: computePoolHotReloadKey(this._config) }); // If preconnect is enabled, establish connection immediately if (this._config.preconnect) { const p = this._warmConnection(); // Attach no-op catch to suppress unhandled rejection warnings p.catch(() => {}); this._connectPromise = p; } } /** * Whether the pool is currently connected. */ get connected(): boolean { return this._connected; } /** * Whether the pool is shutting down (won't attempt reconnection). */ get shuttingDown(): boolean { return this._shuttingDown; } /** * Whether a reconnection attempt is in progress. */ get reconnecting(): boolean { return this._reconnecting; } /** * Pool statistics. */ get stats(): Readonly { return { ...this._stats, connected: this._connected, reconnecting: this._reconnecting, totalCount: this._pool?.totalCount ?? 0, idleCount: this._pool?.idleCount ?? 0, waitingCount: this._pool?.waitingCount ?? 0, }; } /** * Total number of clients in the pool. */ get totalCount(): number { return this._pool?.totalCount ?? 0; } /** * Number of idle clients in the pool. */ get idleCount(): number { return this._pool?.idleCount ?? 0; } /** * Number of clients waiting to be acquired. */ get waitingCount(): number { return this._pool?.waitingCount ?? 0; } /** * Number of expired clients in the pool. */ get expiredCount(): number { const pool = this._pool as (pg.Pool & { expiredCount?: number }) | null; return pool?.expiredCount ?? 0; } /** * Whether the pool is ending (compat with pg.Pool). */ get ending(): boolean { const pool = this._pool as (pg.Pool & { ending?: boolean }) | null; return pool?.ending ?? this._shuttingDown; } /** * Whether the pool has ended (compat with pg.Pool). */ get ended(): boolean { const pool = this._pool as (pg.Pool & { ended?: boolean }) | null; return pool?.ended ?? this._closed; } /** * Pool configuration options. */ get options(): pg.PoolConfig { return this._poolConfig; } /** * Execute a query on the pool. * If reconnection is in progress, waits for it to complete before executing. * Automatically retries on retryable errors. * * @param text - The query string or query config object * @param values - Optional array of parameter values * @returns The query result * * @example * ```typescript * const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]); * console.log(result.rows); * ``` */ query( text: string | pg.QueryConfig, values?: unknown[] ): Promise>; query( text: string | pg.QueryConfig, callback: (err: Error | null, result?: pg.QueryResult) => void ): void; query( text: string, values: unknown[], callback: (err: Error | null, result?: pg.QueryResult) => void ): void; query( text: string | pg.QueryConfig, values?: unknown[] | ((err: Error | null, result?: pg.QueryResult) => void), callback?: (err: Error | null, result?: pg.QueryResult) => void ): Promise> | void { const handler = typeof values === 'function' ? values : callback; const queryValues = typeof values === 'function' ? undefined : values; if (handler) { void this._executeWithRetry(async () => { const pool = await this._ensureConnectedAsync(); return pool.query(text, queryValues); }) .then((result) => handler(null, result)) .catch((error) => handler(error instanceof Error ? error : new Error(String(error)))); return; } return this._executeWithRetry(async () => { const pool = await this._ensureConnectedAsync(); return pool.query(text, queryValues); }); } /** * Acquire a client from the pool. * The client must be released back to the pool when done. * * @returns A pooled client * * @example * ```typescript * const client = await pool.connect(); * try { * await client.query('BEGIN'); * await client.query('INSERT INTO users (name) VALUES ($1)', ['Alice']); * await client.query('COMMIT'); * } catch (error) { * await client.query('ROLLBACK'); * throw error; * } finally { * client.release(); * } * ``` */ connect(): Promise; connect( callback: (err: Error | null, client?: pg.PoolClient, release?: (err?: Error) => void) => void ): void; connect( callback?: ( err: Error | null, client?: pg.PoolClient, release?: (err?: Error) => void ) => void ): Promise | void { if (callback) { void this._executeWithRetry(async () => { const pool = await this._ensureConnectedAsync(); return pool.connect(); }) .then((client) => callback(null, client, client.release.bind(client))) .catch((error) => callback(error instanceof Error ? error : new Error(String(error)))); return; } return this._executeWithRetry(async () => { const pool = await this._ensureConnectedAsync(); return pool.connect(); }); } /** * Signal that the application is shutting down. * This prevents reconnection attempts but doesn't immediately close the pool. * Use this when you want to gracefully drain connections before calling close(). */ shutdown(): void { this._shuttingDown = true; } /** * Close the pool and release all connections. * Alias for end() for compatibility with PostgresClient. */ async close(): Promise { return this.end(); } /** * Close the pool and release all connections. */ async end(): Promise { this._closed = true; this._shuttingDown = true; this._connected = false; this._reconnecting = false; // Remove signal handlers this._removeShutdownHandlers(); // Unregister from global registry unregisterClient(this); if (this._pool) { await this._pool.end(); this._pool = null; } } /** * Access to the raw pg.Pool instance for advanced use cases. * Returns the underlying pg.Pool instance. */ get raw(): pg.Pool { return this._ensureConnected(); } /** * Wait for the connection to be established. * If the connection hasn't been established yet (lazy connection), this will * warm the connection by acquiring and releasing a client. * If reconnection is in progress, waits for it to complete. * * @param timeoutMs - Optional timeout in milliseconds * @throws {ConnectionClosedError} If the pool has been closed or connection fails */ async waitForConnection(timeoutMs?: number): Promise { if (this._connected && this._pool) { return; } if (this._closed) { throw new ConnectionClosedError({ message: 'Pool has been closed', }); } const connectOperation = async () => { // Wait for preconnect if in progress if (this._connectPromise) { try { await this._connectPromise; } catch (err) { this._connectPromise = null; throw err; } this._connectPromise = null; } // Wait for reconnection if in progress if (this._reconnecting && this._reconnectPromise) { await this._reconnectPromise; } // If still not connected, warm the connection if (!this._connected && this._pool) { await this._warmConnection(); } if (!this._connected || !this._pool) { throw new ConnectionClosedError({ message: 'Failed to establish connection', }); } }; if (timeoutMs !== undefined) { let timerId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timerId = setTimeout( () => reject( new QueryTimeoutError({ timeoutMs, }) ), timeoutMs ); }); try { await Promise.race([connectOperation(), timeoutPromise]); } finally { if (timerId !== undefined) { clearTimeout(timerId); } } } else { await connectOperation(); } } /** * Registers signal handlers to detect application shutdown. * When shutdown is detected, reconnection is disabled. */ private _registerShutdownHandlers(): void { const shutdownHandler = () => { this._shuttingDown = true; }; const signals = ['SIGTERM', 'SIGINT'] as const; for (const signal of signals) { process.on(signal, shutdownHandler); this._signalHandlers.push({ signal, handler: shutdownHandler }); } } /** * Removes signal handlers registered for shutdown detection. */ private _removeShutdownHandlers(): void { for (const { signal, handler } of this._signalHandlers) { process.off(signal, handler); } this._signalHandlers = []; } /** * Initializes the pg.Pool instance. */ private _initializePool(): void { if (this._closed || this._pool) { return; } const { reconnect: _reconnect, preconnect: _preconnect, onclose: _onclose, onreconnect: _onreconnect, onreconnected: _onreconnected, onreconnectfailed: _onreconnectfailed, ...pgConfig } = this._config; const poolConfig: pg.PoolConfig = { ...pgConfig }; if (!poolConfig.connectionString) { poolConfig.connectionString = process.env.DATABASE_URL; } // Normalize sslmode=require to sslmode=verify-full to suppress pg v8 // deprecation warning. pg currently treats 'require' as 'verify-full' // but warns that v9 will use weaker libpq semantics. Since we want the // stronger behavior, explicitly use verify-full. if (poolConfig.connectionString) { try { const parsed = new URL(poolConfig.connectionString); const sslmode = parsed.searchParams.get('sslmode'); if (sslmode === 'require') { parsed.searchParams.set('sslmode', 'verify-full'); poolConfig.connectionString = parsed.toString(); } } catch { // Not a parseable URL — leave as-is } } this._poolConfig = poolConfig; this._pool = new pg.Pool(poolConfig); this._pool.on('connect', (client: pg.PoolClient) => { this.emit('connect', client); }); this._pool.on('acquire', (client: pg.PoolClient) => { this.emit('acquire', client); }); ( this._pool as pg.Pool & { on(event: 'release', listener: (client: pg.PoolClient) => void): pg.Pool; } ).on('release', (client: pg.PoolClient) => { this.emit('release', client); }); this._pool.on('remove', (client: pg.PoolClient) => { this.emit('remove', client); }); this._pool.on('error', (err: Error, client?: pg.PoolClient) => { this._handleIdleClientError(err, client); }); } /** * Warms the connection by acquiring and releasing a client. * This verifies the pool can connect to the database. */ private async _warmConnection(): Promise { if (this._closed || this._connected) { return; } if (!this._pool) { this._initializePool(); } // Acquire a client to verify connectivity const client = await this._pool!.connect(); client.release(); this._connected = true; this._stats.totalConnections++; this._stats.lastConnectedAt = new Date(); } /** * Handles idle-client errors from the underlying pg.Pool. * * pg.Pool emits `error` when a connected but idle client is dropped * (proxy idle timeout, server restart, network blip). The pool already * removes that client; remaining clients stay usable and the next * checkout opens a replacement. Treat this as a self-heal signal, not * pool death — do not mark the pool disconnected or tear it down. * * Emitting `error` with zero listeners crashes Node/Bun, so only forward * when a consumer has attached a listener. */ private _handleIdleClientError(error: Error, client?: pg.PoolClient): void { if (this.listenerCount('error') > 0) { this.emit('error', error, client); } } /** * Ensures the pool is initialized and returns it. */ private _ensureConnected(): pg.Pool { if (this._closed) { throw new ConnectionClosedError({ message: 'Pool has been closed', }); } if (!this._pool) { throw new ConnectionClosedError({ message: 'Pool not initialized', wasReconnecting: this._reconnecting, }); } return this._pool; } /** * Ensures the pool is connected and returns it. * If reconnection is in progress, waits for it to complete. * If connection hasn't been established yet, warms it first. */ private async _ensureConnectedAsync(): Promise { if (this._closed) { throw new ConnectionClosedError({ message: 'Pool has been closed', }); } // If preconnect is in progress, wait for it if (this._connectPromise) { try { await this._connectPromise; } catch (err) { this._connectPromise = null; throw err; } this._connectPromise = null; } // If reconnection is in progress, wait for it to complete if (this._reconnecting && this._reconnectPromise) { await this._reconnectPromise; } if (!this._pool) { throw new ConnectionClosedError({ message: 'Pool not initialized', wasReconnecting: false, }); } // If not yet connected, warm the connection if (!this._connected) { await this._warmConnection(); } return this._pool; } /** * Executes an operation with retry logic for retryable errors. * Waits for reconnection if one is in progress. */ private async _executeWithRetry( operation: () => T | Promise, maxRetries: number = 3 ): Promise { let lastError: Error | undefined; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { // Wait for preconnect if in progress if (this._connectPromise) { try { await this._connectPromise; } catch (err) { this._connectPromise = null; throw err; } this._connectPromise = null; } // Wait for reconnection if in progress if (this._reconnecting && this._reconnectPromise) { await this._reconnectPromise; } if (!this._pool) { throw new ConnectionClosedError({ message: 'Pool not initialized', wasReconnecting: this._reconnecting, }); } // If not yet connected, warm the connection if (!this._connected) { await this._warmConnection(); } return await operation(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); // If it's a retryable error and we have retries left, wait and retry if (isRetryableError(error) && attempt < maxRetries) { // Wait for reconnection to complete if it started if (this._reconnecting && this._reconnectPromise) { try { await this._reconnectPromise; } catch { // Reconnection failed, will throw below } } continue; } throw error; } } throw lastError; } } /** * Creates a new PostgresPool. * * @param config - Connection configuration * @param options - Pool creation options. Set `shared: true` during Bun hot reload * development to reuse one pool across module re-evaluations. * @returns A PostgresPool instance */ export function createPool( config?: string | PoolConfig, options?: CreatePoolOptions ): PostgresPool { if (options?.shared && isHotReloadEnabled()) { const key = computePoolHotReloadKey(config); const cached = getSharedHotReloadPool(key); if (cached) { return cached as PostgresPool; } } return new PostgresPool(config); } /** * Alias for PostgresPool for convenient imports. */ export { PostgresPool as Pool };