@agentuity/postgres 1.0.0 → 1.0.2

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/src/pool.ts ADDED
@@ -0,0 +1,661 @@
1
+ import pg from 'pg';
2
+ import type { PoolConfig, PoolStats } from './types';
3
+ import {
4
+ ConnectionClosedError,
5
+ PostgresError,
6
+ QueryTimeoutError,
7
+ ReconnectFailedError,
8
+ isRetryableError,
9
+ } from './errors';
10
+ import { computeBackoff, sleep, mergeReconnectConfig } from './reconnect';
11
+ import { registerClient, unregisterClient, type Registrable } from './registry';
12
+
13
+ /**
14
+ * A resilient PostgreSQL connection pool with automatic reconnection.
15
+ *
16
+ * Wraps the `pg` package's Pool and adds:
17
+ * - Automatic reconnection with exponential backoff
18
+ * - Connection state tracking
19
+ * - Pool statistics
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * const pool = new PostgresPool({
24
+ * connectionString: process.env.DATABASE_URL,
25
+ * max: 20,
26
+ * reconnect: { maxAttempts: 5 }
27
+ * });
28
+ *
29
+ * // Execute queries
30
+ * const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
31
+ *
32
+ * // Close when done
33
+ * await pool.close();
34
+ * ```
35
+ */
36
+ export class PostgresPool implements Registrable {
37
+ private _pool: pg.Pool | null = null;
38
+ private _config: PoolConfig;
39
+ private _connected = false;
40
+ private _reconnecting = false;
41
+ private _closed = false;
42
+ private _shuttingDown = false;
43
+ private _signalHandlers: { signal: string; handler: () => void }[] = [];
44
+ private _reconnectPromise: Promise<void> | null = null;
45
+ private _connectPromise: Promise<void> | null = null;
46
+
47
+ private _stats: Omit<
48
+ PoolStats,
49
+ 'connected' | 'reconnecting' | 'totalCount' | 'idleCount' | 'waitingCount'
50
+ > = {
51
+ totalConnections: 0,
52
+ reconnectAttempts: 0,
53
+ failedReconnects: 0,
54
+ lastConnectedAt: null,
55
+ lastDisconnectedAt: null,
56
+ lastReconnectAttemptAt: null,
57
+ };
58
+
59
+ /**
60
+ * Creates a new PostgresPool.
61
+ *
62
+ * Note: By default, the actual connection is established lazily on first query.
63
+ * Set `preconnect: true` in config to verify connectivity immediately.
64
+ *
65
+ * @param config - Connection configuration. Can be a connection URL string or a config object.
66
+ * If not provided, uses `process.env.DATABASE_URL`.
67
+ */
68
+ constructor(config?: string | PoolConfig) {
69
+ if (typeof config === 'string') {
70
+ this._config = { connectionString: config };
71
+ } else {
72
+ this._config = config ?? {};
73
+ }
74
+
75
+ // Initialize the pool
76
+ this._initializePool();
77
+
78
+ // Register shutdown signal handlers to prevent reconnection during app shutdown
79
+ this._registerShutdownHandlers();
80
+
81
+ // Register this pool in the global registry for coordinated shutdown
82
+ registerClient(this);
83
+
84
+ // If preconnect is enabled, establish connection immediately
85
+ if (this._config.preconnect) {
86
+ const p = this._warmConnection();
87
+ // Attach no-op catch to suppress unhandled rejection warnings
88
+ p.catch(() => {});
89
+ this._connectPromise = p;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Whether the pool is currently connected.
95
+ */
96
+ get connected(): boolean {
97
+ return this._connected;
98
+ }
99
+
100
+ /**
101
+ * Whether the pool is shutting down (won't attempt reconnection).
102
+ */
103
+ get shuttingDown(): boolean {
104
+ return this._shuttingDown;
105
+ }
106
+
107
+ /**
108
+ * Whether a reconnection attempt is in progress.
109
+ */
110
+ get reconnecting(): boolean {
111
+ return this._reconnecting;
112
+ }
113
+
114
+ /**
115
+ * Pool statistics.
116
+ */
117
+ get stats(): Readonly<PoolStats> {
118
+ return {
119
+ ...this._stats,
120
+ connected: this._connected,
121
+ reconnecting: this._reconnecting,
122
+ totalCount: this._pool?.totalCount ?? 0,
123
+ idleCount: this._pool?.idleCount ?? 0,
124
+ waitingCount: this._pool?.waitingCount ?? 0,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Execute a query on the pool.
130
+ * If reconnection is in progress, waits for it to complete before executing.
131
+ * Automatically retries on retryable errors.
132
+ *
133
+ * @param text - The query string or query config object
134
+ * @param values - Optional array of parameter values
135
+ * @returns The query result
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
140
+ * console.log(result.rows);
141
+ * ```
142
+ */
143
+ async query<T extends pg.QueryResultRow = pg.QueryResultRow>(
144
+ text: string | pg.QueryConfig<unknown[]>,
145
+ values?: unknown[]
146
+ ): Promise<pg.QueryResult<T>> {
147
+ return this._executeWithRetry(async () => {
148
+ const pool = await this._ensureConnectedAsync();
149
+ return pool.query<T>(text, values);
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Acquire a client from the pool.
155
+ * The client must be released back to the pool when done.
156
+ *
157
+ * @returns A pooled client
158
+ *
159
+ * @example
160
+ * ```typescript
161
+ * const client = await pool.connect();
162
+ * try {
163
+ * await client.query('BEGIN');
164
+ * await client.query('INSERT INTO users (name) VALUES ($1)', ['Alice']);
165
+ * await client.query('COMMIT');
166
+ * } catch (error) {
167
+ * await client.query('ROLLBACK');
168
+ * throw error;
169
+ * } finally {
170
+ * client.release();
171
+ * }
172
+ * ```
173
+ */
174
+ async connect(): Promise<pg.PoolClient> {
175
+ return this._executeWithRetry(async () => {
176
+ const pool = await this._ensureConnectedAsync();
177
+ return pool.connect();
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Signal that the application is shutting down.
183
+ * This prevents reconnection attempts but doesn't immediately close the pool.
184
+ * Use this when you want to gracefully drain connections before calling close().
185
+ */
186
+ shutdown(): void {
187
+ this._shuttingDown = true;
188
+ }
189
+
190
+ /**
191
+ * Close the pool and release all connections.
192
+ * Alias for end() for compatibility with PostgresClient.
193
+ */
194
+ async close(): Promise<void> {
195
+ return this.end();
196
+ }
197
+
198
+ /**
199
+ * Close the pool and release all connections.
200
+ */
201
+ async end(): Promise<void> {
202
+ this._closed = true;
203
+ this._shuttingDown = true;
204
+ this._connected = false;
205
+ this._reconnecting = false;
206
+
207
+ // Remove signal handlers
208
+ this._removeShutdownHandlers();
209
+
210
+ // Unregister from global registry
211
+ unregisterClient(this);
212
+
213
+ if (this._pool) {
214
+ await this._pool.end();
215
+ this._pool = null;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Access to the raw pg.Pool instance for advanced use cases.
221
+ * Returns the underlying pg.Pool instance.
222
+ */
223
+ get raw(): pg.Pool {
224
+ return this._ensureConnected();
225
+ }
226
+
227
+ /**
228
+ * Wait for the connection to be established.
229
+ * If the connection hasn't been established yet (lazy connection), this will
230
+ * warm the connection by acquiring and releasing a client.
231
+ * If reconnection is in progress, waits for it to complete.
232
+ *
233
+ * @param timeoutMs - Optional timeout in milliseconds
234
+ * @throws {ConnectionClosedError} If the pool has been closed or connection fails
235
+ */
236
+ async waitForConnection(timeoutMs?: number): Promise<void> {
237
+ if (this._connected && this._pool) {
238
+ return;
239
+ }
240
+
241
+ if (this._closed) {
242
+ throw new ConnectionClosedError({
243
+ message: 'Pool has been closed',
244
+ });
245
+ }
246
+
247
+ const connectOperation = async () => {
248
+ // Wait for preconnect if in progress
249
+ if (this._connectPromise) {
250
+ try {
251
+ await this._connectPromise;
252
+ } catch (err) {
253
+ this._connectPromise = null;
254
+ throw err;
255
+ }
256
+ this._connectPromise = null;
257
+ }
258
+
259
+ // Wait for reconnection if in progress
260
+ if (this._reconnecting && this._reconnectPromise) {
261
+ await this._reconnectPromise;
262
+ }
263
+
264
+ // If still not connected, warm the connection
265
+ if (!this._connected && this._pool) {
266
+ await this._warmConnection();
267
+ }
268
+
269
+ if (!this._connected || !this._pool) {
270
+ throw new ConnectionClosedError({
271
+ message: 'Failed to establish connection',
272
+ });
273
+ }
274
+ };
275
+
276
+ if (timeoutMs !== undefined) {
277
+ let timerId: ReturnType<typeof setTimeout> | undefined;
278
+ const timeoutPromise = new Promise<never>((_, reject) => {
279
+ timerId = setTimeout(
280
+ () =>
281
+ reject(
282
+ new QueryTimeoutError({
283
+ timeoutMs,
284
+ })
285
+ ),
286
+ timeoutMs
287
+ );
288
+ });
289
+ try {
290
+ await Promise.race([connectOperation(), timeoutPromise]);
291
+ } finally {
292
+ if (timerId !== undefined) {
293
+ clearTimeout(timerId);
294
+ }
295
+ }
296
+ } else {
297
+ await connectOperation();
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Registers signal handlers to detect application shutdown.
303
+ * When shutdown is detected, reconnection is disabled.
304
+ */
305
+ private _registerShutdownHandlers(): void {
306
+ const shutdownHandler = () => {
307
+ this._shuttingDown = true;
308
+ };
309
+
310
+ const signals = ['SIGTERM', 'SIGINT'] as const;
311
+ for (const signal of signals) {
312
+ process.on(signal, shutdownHandler);
313
+ this._signalHandlers.push({ signal, handler: shutdownHandler });
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Removes signal handlers registered for shutdown detection.
319
+ */
320
+ private _removeShutdownHandlers(): void {
321
+ for (const { signal, handler } of this._signalHandlers) {
322
+ process.off(signal, handler);
323
+ }
324
+ this._signalHandlers = [];
325
+ }
326
+
327
+ /**
328
+ * Initializes the pg.Pool instance.
329
+ */
330
+ private _initializePool(): void {
331
+ if (this._closed || this._pool) {
332
+ return;
333
+ }
334
+
335
+ const connectionString = this._config.connectionString ?? process.env.DATABASE_URL;
336
+
337
+ const poolConfig: pg.PoolConfig = {};
338
+
339
+ if (connectionString) {
340
+ poolConfig.connectionString = connectionString;
341
+ }
342
+
343
+ if (this._config.host) poolConfig.host = this._config.host;
344
+ if (this._config.port) poolConfig.port = this._config.port;
345
+ if (this._config.user) poolConfig.user = this._config.user;
346
+ if (this._config.password) poolConfig.password = this._config.password;
347
+ if (this._config.database) poolConfig.database = this._config.database;
348
+ if (this._config.max) poolConfig.max = this._config.max;
349
+ if (this._config.idleTimeoutMillis !== undefined)
350
+ poolConfig.idleTimeoutMillis = this._config.idleTimeoutMillis;
351
+ if (this._config.connectionTimeoutMillis !== undefined)
352
+ poolConfig.connectionTimeoutMillis = this._config.connectionTimeoutMillis;
353
+
354
+ // Handle SSL configuration
355
+ if (this._config.ssl !== undefined) {
356
+ if (typeof this._config.ssl === 'boolean') {
357
+ poolConfig.ssl = this._config.ssl;
358
+ } else {
359
+ poolConfig.ssl = this._config.ssl;
360
+ }
361
+ }
362
+
363
+ this._pool = new pg.Pool(poolConfig);
364
+
365
+ // Handle pool error events for reconnection
366
+ this._pool.on('error', (err: Error) => {
367
+ this._handlePoolError(err);
368
+ });
369
+ }
370
+
371
+ /**
372
+ * Warms the connection by acquiring and releasing a client.
373
+ * This verifies the pool can connect to the database.
374
+ */
375
+ private async _warmConnection(): Promise<void> {
376
+ if (this._closed || this._connected) {
377
+ return;
378
+ }
379
+
380
+ if (!this._pool) {
381
+ this._initializePool();
382
+ }
383
+
384
+ // Acquire a client to verify connectivity
385
+ const client = await this._pool!.connect();
386
+ client.release();
387
+
388
+ this._connected = true;
389
+ this._stats.totalConnections++;
390
+ this._stats.lastConnectedAt = new Date();
391
+ }
392
+
393
+ /**
394
+ * Re-initializes the pool for reconnection.
395
+ */
396
+ private _reinitializePool(): void {
397
+ this._connected = false;
398
+ this._pool = null;
399
+ this._initializePool();
400
+ }
401
+
402
+ /**
403
+ * Handles pool error events.
404
+ */
405
+ private _handlePoolError(error: Error): void {
406
+ const wasConnected = this._connected;
407
+ this._connected = false;
408
+ this._stats.lastDisconnectedAt = new Date();
409
+
410
+ // Call user's onclose callback
411
+ this._config.onclose?.(error);
412
+
413
+ // Don't reconnect if explicitly closed OR if application is shutting down
414
+ if (this._closed || this._shuttingDown) {
415
+ return;
416
+ }
417
+
418
+ // Check if reconnection is enabled
419
+ const reconnectConfig = mergeReconnectConfig(this._config.reconnect);
420
+ if (!reconnectConfig.enabled) {
421
+ return;
422
+ }
423
+
424
+ // Check if it's a retryable error
425
+ if (!isRetryableError(error)) {
426
+ return;
427
+ }
428
+
429
+ // Start reconnection if not already in progress
430
+ if (!this._reconnecting && wasConnected) {
431
+ this._startReconnect();
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Starts the reconnection process.
437
+ */
438
+ private _startReconnect(): void {
439
+ if (this._reconnecting || this._closed || this._shuttingDown) {
440
+ return;
441
+ }
442
+
443
+ this._reconnecting = true;
444
+ this._reconnectPromise = this._reconnectLoop();
445
+ }
446
+
447
+ /**
448
+ * The main reconnection loop with exponential backoff.
449
+ */
450
+ private async _reconnectLoop(): Promise<void> {
451
+ const config = mergeReconnectConfig(this._config.reconnect);
452
+ let attempt = 0;
453
+ let lastError: Error | undefined;
454
+
455
+ while (attempt < config.maxAttempts && !this._closed && !this._shuttingDown) {
456
+ this._stats.reconnectAttempts++;
457
+ this._stats.lastReconnectAttemptAt = new Date();
458
+
459
+ // Notify about reconnection attempt
460
+ this._config.onreconnect?.(attempt + 1);
461
+
462
+ // Calculate backoff delay
463
+ const delay = computeBackoff(attempt, config);
464
+
465
+ // Wait before attempting
466
+ await sleep(delay);
467
+
468
+ if (this._closed) {
469
+ break;
470
+ }
471
+
472
+ try {
473
+ // Close existing pool if any
474
+ if (this._pool) {
475
+ try {
476
+ await this._pool.end();
477
+ } catch {
478
+ // Ignore close errors
479
+ }
480
+ this._pool = null;
481
+ }
482
+
483
+ // Attempt to reconnect
484
+ this._reinitializePool();
485
+ await this._warmConnection();
486
+
487
+ // Success!
488
+ this._reconnecting = false;
489
+ this._reconnectPromise = null;
490
+ this._config.onreconnected?.();
491
+ return;
492
+ } catch (error) {
493
+ lastError =
494
+ error instanceof Error
495
+ ? error
496
+ : new PostgresError({
497
+ message: String(error),
498
+ });
499
+ this._stats.failedReconnects++;
500
+ attempt++;
501
+ }
502
+ }
503
+
504
+ // All attempts failed
505
+ this._reconnecting = false;
506
+ this._reconnectPromise = null;
507
+
508
+ // Only invoke callback if not explicitly closed/shutdown
509
+ if (!this._closed && !this._shuttingDown) {
510
+ const finalError = new ReconnectFailedError({
511
+ attempts: attempt,
512
+ lastError,
513
+ });
514
+
515
+ this._config.onreconnectfailed?.(finalError);
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Ensures the pool is initialized and returns it.
521
+ */
522
+ private _ensureConnected(): pg.Pool {
523
+ if (this._closed) {
524
+ throw new ConnectionClosedError({
525
+ message: 'Pool has been closed',
526
+ });
527
+ }
528
+
529
+ if (!this._pool) {
530
+ throw new ConnectionClosedError({
531
+ message: 'Pool not initialized',
532
+ wasReconnecting: this._reconnecting,
533
+ });
534
+ }
535
+
536
+ return this._pool;
537
+ }
538
+
539
+ /**
540
+ * Ensures the pool is connected and returns it.
541
+ * If reconnection is in progress, waits for it to complete.
542
+ * If connection hasn't been established yet, warms it first.
543
+ */
544
+ private async _ensureConnectedAsync(): Promise<pg.Pool> {
545
+ if (this._closed) {
546
+ throw new ConnectionClosedError({
547
+ message: 'Pool has been closed',
548
+ });
549
+ }
550
+
551
+ // If preconnect is in progress, wait for it
552
+ if (this._connectPromise) {
553
+ try {
554
+ await this._connectPromise;
555
+ } catch (err) {
556
+ this._connectPromise = null;
557
+ throw err;
558
+ }
559
+ this._connectPromise = null;
560
+ }
561
+
562
+ // If reconnection is in progress, wait for it to complete
563
+ if (this._reconnecting && this._reconnectPromise) {
564
+ await this._reconnectPromise;
565
+ }
566
+
567
+ if (!this._pool) {
568
+ throw new ConnectionClosedError({
569
+ message: 'Pool not initialized',
570
+ wasReconnecting: false,
571
+ });
572
+ }
573
+
574
+ // If not yet connected, warm the connection
575
+ if (!this._connected) {
576
+ await this._warmConnection();
577
+ }
578
+
579
+ return this._pool;
580
+ }
581
+
582
+ /**
583
+ * Executes an operation with retry logic for retryable errors.
584
+ * Waits for reconnection if one is in progress.
585
+ */
586
+ private async _executeWithRetry<T>(
587
+ operation: () => T | Promise<T>,
588
+ maxRetries: number = 3
589
+ ): Promise<T> {
590
+ let lastError: Error | undefined;
591
+
592
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
593
+ try {
594
+ // Wait for preconnect if in progress
595
+ if (this._connectPromise) {
596
+ try {
597
+ await this._connectPromise;
598
+ } catch (err) {
599
+ this._connectPromise = null;
600
+ throw err;
601
+ }
602
+ this._connectPromise = null;
603
+ }
604
+
605
+ // Wait for reconnection if in progress
606
+ if (this._reconnecting && this._reconnectPromise) {
607
+ await this._reconnectPromise;
608
+ }
609
+
610
+ if (!this._pool) {
611
+ throw new ConnectionClosedError({
612
+ message: 'Pool not initialized',
613
+ wasReconnecting: this._reconnecting,
614
+ });
615
+ }
616
+
617
+ // If not yet connected, warm the connection
618
+ if (!this._connected) {
619
+ await this._warmConnection();
620
+ }
621
+
622
+ return await operation();
623
+ } catch (error) {
624
+ lastError = error instanceof Error ? error : new Error(String(error));
625
+
626
+ // If it's a retryable error and we have retries left, wait and retry
627
+ if (isRetryableError(error) && attempt < maxRetries) {
628
+ // Wait for reconnection to complete if it started
629
+ if (this._reconnecting && this._reconnectPromise) {
630
+ try {
631
+ await this._reconnectPromise;
632
+ } catch {
633
+ // Reconnection failed, will throw below
634
+ }
635
+ }
636
+ continue;
637
+ }
638
+
639
+ throw error;
640
+ }
641
+ }
642
+
643
+ throw lastError;
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Creates a new PostgresPool.
649
+ * This is an alias for `new PostgresPool(config)` for convenience.
650
+ *
651
+ * @param config - Connection configuration
652
+ * @returns A new PostgresPool instance
653
+ */
654
+ export function createPool(config?: string | PoolConfig): PostgresPool {
655
+ return new PostgresPool(config);
656
+ }
657
+
658
+ /**
659
+ * Alias for PostgresPool for convenient imports.
660
+ */
661
+ export { PostgresPool as Pool };