@agentuity/postgres 0.1.41
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/AGENTS.md +124 -0
- package/README.md +297 -0
- package/dist/client.d.ts +224 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +670 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +109 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +115 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/patch.d.ts +65 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +111 -0
- package/dist/patch.js.map +1 -0
- package/dist/postgres.d.ts +62 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +63 -0
- package/dist/postgres.js.map +1 -0
- package/dist/reconnect.d.ts +31 -0
- package/dist/reconnect.d.ts.map +1 -0
- package/dist/reconnect.js +60 -0
- package/dist/reconnect.js.map +1 -0
- package/dist/registry.d.ts +71 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +175 -0
- package/dist/registry.js.map +1 -0
- package/dist/transaction.d.ts +147 -0
- package/dist/transaction.d.ts.map +1 -0
- package/dist/transaction.js +287 -0
- package/dist/transaction.js.map +1 -0
- package/dist/types.d.ts +213 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
- package/src/client.ts +776 -0
- package/src/errors.ts +154 -0
- package/src/index.ts +71 -0
- package/src/patch.ts +123 -0
- package/src/postgres.ts +65 -0
- package/src/reconnect.ts +74 -0
- package/src/registry.ts +194 -0
- package/src/transaction.ts +312 -0
- package/src/types.ts +250 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import { SQL as BunSQL, type SQLQuery, type SQL } from 'bun';
|
|
2
|
+
import type { PostgresConfig, ConnectionStats, TransactionOptions, ReserveOptions } from './types';
|
|
3
|
+
import {
|
|
4
|
+
ConnectionClosedError,
|
|
5
|
+
PostgresError,
|
|
6
|
+
ReconnectFailedError,
|
|
7
|
+
QueryTimeoutError,
|
|
8
|
+
UnsupportedOperationError,
|
|
9
|
+
isRetryableError,
|
|
10
|
+
} from './errors';
|
|
11
|
+
import { computeBackoff, sleep, mergeReconnectConfig } from './reconnect';
|
|
12
|
+
import { Transaction, ReservedConnection } from './transaction';
|
|
13
|
+
import { registerClient, unregisterClient } from './registry';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Bun SQL options for PostgreSQL connections.
|
|
17
|
+
* We use a type assertion since the Bun types are a union of SQLite and Postgres options.
|
|
18
|
+
*/
|
|
19
|
+
type BunPostgresOptions = SQL.PostgresOrMySQLOptions;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A resilient PostgreSQL client with automatic reconnection.
|
|
23
|
+
*
|
|
24
|
+
* Wraps Bun's native SQL driver and adds:
|
|
25
|
+
* - Automatic reconnection with exponential backoff
|
|
26
|
+
* - Connection state tracking
|
|
27
|
+
* - Transaction support
|
|
28
|
+
* - Reserved connection support
|
|
29
|
+
*
|
|
30
|
+
* Can be used as a tagged template literal for queries:
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const client = new PostgresClient();
|
|
35
|
+
*
|
|
36
|
+
* // Simple query
|
|
37
|
+
* const users = await client`SELECT * FROM users`;
|
|
38
|
+
*
|
|
39
|
+
* // Parameterized query
|
|
40
|
+
* const user = await client`SELECT * FROM users WHERE id = ${userId}`;
|
|
41
|
+
*
|
|
42
|
+
* // Transaction
|
|
43
|
+
* const tx = await client.begin();
|
|
44
|
+
* await tx`INSERT INTO users (name) VALUES (${name})`;
|
|
45
|
+
* await tx.commit();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export class PostgresClient {
|
|
49
|
+
private _sql: InstanceType<typeof BunSQL> | null = null;
|
|
50
|
+
private _config: PostgresConfig;
|
|
51
|
+
private _initialized = false; // SQL client created (lazy connection)
|
|
52
|
+
private _connected = false; // Actual TCP connection verified
|
|
53
|
+
private _reconnecting = false;
|
|
54
|
+
private _closed = false;
|
|
55
|
+
private _shuttingDown = false;
|
|
56
|
+
private _signalHandlers: { signal: string; handler: () => void }[] = [];
|
|
57
|
+
private _reconnectPromise: Promise<void> | null = null;
|
|
58
|
+
private _connectPromise: Promise<void> | null = null;
|
|
59
|
+
|
|
60
|
+
private _stats: ConnectionStats = {
|
|
61
|
+
connected: false,
|
|
62
|
+
reconnecting: false,
|
|
63
|
+
totalConnections: 0,
|
|
64
|
+
reconnectAttempts: 0,
|
|
65
|
+
failedReconnects: 0,
|
|
66
|
+
lastConnectedAt: null,
|
|
67
|
+
lastDisconnectedAt: null,
|
|
68
|
+
lastReconnectAttemptAt: null,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Creates a new PostgresClient.
|
|
73
|
+
*
|
|
74
|
+
* Note: By default, the actual TCP connection is established lazily on first query.
|
|
75
|
+
* The `connected` property will be `false` until a query is executed or
|
|
76
|
+
* `waitForConnection()` is called. Set `preconnect: true` in config to
|
|
77
|
+
* establish the connection immediately.
|
|
78
|
+
*
|
|
79
|
+
* @param config - Connection configuration. Can be a connection URL string or a config object.
|
|
80
|
+
* If not provided, uses `process.env.DATABASE_URL`.
|
|
81
|
+
*/
|
|
82
|
+
constructor(config?: string | PostgresConfig) {
|
|
83
|
+
if (typeof config === 'string') {
|
|
84
|
+
this._config = { url: config };
|
|
85
|
+
} else {
|
|
86
|
+
this._config = config ?? {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Initialize the SQL client (lazy - doesn't establish TCP connection yet)
|
|
90
|
+
this._initializeSql();
|
|
91
|
+
|
|
92
|
+
// Register shutdown signal handlers to prevent reconnection during app shutdown
|
|
93
|
+
this._registerShutdownHandlers();
|
|
94
|
+
|
|
95
|
+
// Register this client in the global registry for coordinated shutdown
|
|
96
|
+
registerClient(this);
|
|
97
|
+
|
|
98
|
+
// If preconnect is enabled, establish connection immediately
|
|
99
|
+
if (this._config.preconnect) {
|
|
100
|
+
const p = this._warmConnection();
|
|
101
|
+
// Attach no-op catch to suppress unhandled rejection warnings
|
|
102
|
+
// Later awaits will still observe the real rejection
|
|
103
|
+
p.catch(() => {});
|
|
104
|
+
this._connectPromise = p;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether the client is currently connected.
|
|
110
|
+
*/
|
|
111
|
+
get connected(): boolean {
|
|
112
|
+
return this._connected;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether the client is shutting down (won't attempt reconnection).
|
|
117
|
+
*/
|
|
118
|
+
get shuttingDown(): boolean {
|
|
119
|
+
return this._shuttingDown;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether a reconnection attempt is in progress.
|
|
124
|
+
*/
|
|
125
|
+
get reconnecting(): boolean {
|
|
126
|
+
return this._reconnecting;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Connection statistics.
|
|
131
|
+
*/
|
|
132
|
+
get stats(): Readonly<ConnectionStats> {
|
|
133
|
+
return {
|
|
134
|
+
...this._stats,
|
|
135
|
+
connected: this._connected,
|
|
136
|
+
reconnecting: this._reconnecting,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Execute a query using tagged template literal syntax.
|
|
142
|
+
* If reconnection is in progress, waits for it to complete before executing.
|
|
143
|
+
* Automatically retries on retryable errors.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```typescript
|
|
147
|
+
* const users = await client`SELECT * FROM users WHERE active = ${true}`;
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
query(strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]> {
|
|
151
|
+
return this._executeWithRetry(async () => {
|
|
152
|
+
const sql = await this._ensureConnectedAsync();
|
|
153
|
+
return sql(strings, ...values);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Begin a new transaction.
|
|
159
|
+
*
|
|
160
|
+
* @param options - Transaction options (isolation level, read-only, deferrable)
|
|
161
|
+
* @returns A Transaction object for executing queries within the transaction
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* const tx = await client.begin();
|
|
166
|
+
* try {
|
|
167
|
+
* await tx`INSERT INTO users (name) VALUES (${name})`;
|
|
168
|
+
* await tx`UPDATE accounts SET balance = balance - ${amount} WHERE id = ${fromId}`;
|
|
169
|
+
* await tx.commit();
|
|
170
|
+
* } catch (error) {
|
|
171
|
+
* await tx.rollback();
|
|
172
|
+
* throw error;
|
|
173
|
+
* }
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
async begin(options?: TransactionOptions): Promise<Transaction> {
|
|
177
|
+
// Use async ensure to wait for connection/reconnect completion
|
|
178
|
+
// This ensures _warmConnection() updates connection stats before we proceed
|
|
179
|
+
const sql = await this._ensureConnectedAsync();
|
|
180
|
+
|
|
181
|
+
// Build BEGIN statement with options
|
|
182
|
+
let beginStatement = 'BEGIN';
|
|
183
|
+
|
|
184
|
+
if (options?.isolationLevel) {
|
|
185
|
+
beginStatement += ` ISOLATION LEVEL ${options.isolationLevel.toUpperCase()}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (options?.readOnly) {
|
|
189
|
+
beginStatement += ' READ ONLY';
|
|
190
|
+
} else if (options?.readOnly === false) {
|
|
191
|
+
beginStatement += ' READ WRITE';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (options?.deferrable === true) {
|
|
195
|
+
beginStatement += ' DEFERRABLE';
|
|
196
|
+
} else if (options?.deferrable === false) {
|
|
197
|
+
beginStatement += ' NOT DEFERRABLE';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Execute BEGIN
|
|
201
|
+
const connection = await sql.unsafe(beginStatement);
|
|
202
|
+
|
|
203
|
+
return new Transaction(sql, connection);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Reserve an exclusive connection from the pool.
|
|
208
|
+
*
|
|
209
|
+
* **Note:** This feature is not currently supported because Bun's SQL driver
|
|
210
|
+
* does not expose connection-level pooling APIs. The underlying driver manages
|
|
211
|
+
* connections internally and does not allow reserving a specific connection.
|
|
212
|
+
*
|
|
213
|
+
* @param _options - Reserve options (unused)
|
|
214
|
+
* @throws {UnsupportedOperationError} Always throws - this operation is not supported
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```typescript
|
|
218
|
+
* // This will throw UnsupportedOperationError
|
|
219
|
+
* const conn = await client.reserve();
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
async reserve(_options?: ReserveOptions): Promise<ReservedConnection> {
|
|
223
|
+
throw new UnsupportedOperationError({
|
|
224
|
+
operation: 'reserve',
|
|
225
|
+
reason:
|
|
226
|
+
"Bun's SQL driver does not expose connection-level pooling APIs. " +
|
|
227
|
+
'Use transactions (begin/commit) for operations that require session-level state.',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Signal that the application is shutting down.
|
|
233
|
+
* This prevents reconnection attempts but doesn't immediately close the connection.
|
|
234
|
+
* Use this when you want to gracefully drain connections before calling close().
|
|
235
|
+
*/
|
|
236
|
+
shutdown(): void {
|
|
237
|
+
this._shuttingDown = true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Close the client and release all connections.
|
|
242
|
+
*/
|
|
243
|
+
async close(): Promise<void> {
|
|
244
|
+
this._closed = true;
|
|
245
|
+
this._shuttingDown = true; // Also set shuttingDown to prevent any race conditions
|
|
246
|
+
this._connected = false;
|
|
247
|
+
this._reconnecting = false;
|
|
248
|
+
|
|
249
|
+
// Remove signal handlers
|
|
250
|
+
this._removeShutdownHandlers();
|
|
251
|
+
|
|
252
|
+
// Unregister from global registry
|
|
253
|
+
unregisterClient(this);
|
|
254
|
+
|
|
255
|
+
if (this._sql) {
|
|
256
|
+
await this._sql.close();
|
|
257
|
+
this._sql = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Access to raw SQL methods for advanced use cases.
|
|
263
|
+
* Returns the underlying Bun.SQL instance.
|
|
264
|
+
*/
|
|
265
|
+
get raw(): InstanceType<typeof BunSQL> {
|
|
266
|
+
return this._ensureConnected();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Execute an unsafe (unparameterized) query.
|
|
271
|
+
* Use with caution - this bypasses SQL injection protection.
|
|
272
|
+
*
|
|
273
|
+
* @param query - The raw SQL query string
|
|
274
|
+
*/
|
|
275
|
+
unsafe(query: string): SQLQuery {
|
|
276
|
+
const sql = this._ensureConnected();
|
|
277
|
+
return sql.unsafe(query);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Registers signal handlers to detect application shutdown.
|
|
282
|
+
* When shutdown is detected, reconnection is disabled.
|
|
283
|
+
*/
|
|
284
|
+
private _registerShutdownHandlers(): void {
|
|
285
|
+
const shutdownHandler = () => {
|
|
286
|
+
this._shuttingDown = true;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Listen for common shutdown signals
|
|
290
|
+
const signals = ['SIGTERM', 'SIGINT'] as const;
|
|
291
|
+
for (const signal of signals) {
|
|
292
|
+
process.on(signal, shutdownHandler);
|
|
293
|
+
this._signalHandlers.push({ signal, handler: shutdownHandler });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Removes signal handlers registered for shutdown detection.
|
|
299
|
+
*/
|
|
300
|
+
private _removeShutdownHandlers(): void {
|
|
301
|
+
for (const { signal, handler } of this._signalHandlers) {
|
|
302
|
+
process.off(signal, handler);
|
|
303
|
+
}
|
|
304
|
+
this._signalHandlers = [];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Initializes the internal Bun.SQL client.
|
|
309
|
+
* Note: This creates the SQL client but doesn't establish the TCP connection yet.
|
|
310
|
+
* Bun's SQL driver uses lazy connections - the actual TCP connection is made on first query.
|
|
311
|
+
*/
|
|
312
|
+
private _initializeSql(): void {
|
|
313
|
+
if (this._closed || this._initialized) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const url = this._config.url ?? process.env.DATABASE_URL;
|
|
318
|
+
|
|
319
|
+
// Build Bun.SQL options - use type assertion since Bun types are a union
|
|
320
|
+
const bunOptions: BunPostgresOptions = {
|
|
321
|
+
adapter: 'postgres',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
if (url) {
|
|
325
|
+
bunOptions.url = url;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (this._config.hostname) bunOptions.hostname = this._config.hostname;
|
|
329
|
+
if (this._config.port) bunOptions.port = this._config.port;
|
|
330
|
+
if (this._config.username) bunOptions.username = this._config.username;
|
|
331
|
+
if (this._config.password) bunOptions.password = this._config.password;
|
|
332
|
+
if (this._config.database) bunOptions.database = this._config.database;
|
|
333
|
+
if (this._config.max) bunOptions.max = this._config.max;
|
|
334
|
+
if (this._config.idleTimeout !== undefined) bunOptions.idleTimeout = this._config.idleTimeout;
|
|
335
|
+
if (this._config.connectionTimeout !== undefined)
|
|
336
|
+
bunOptions.connectionTimeout = this._config.connectionTimeout;
|
|
337
|
+
|
|
338
|
+
// Handle TLS configuration
|
|
339
|
+
if (this._config.tls !== undefined) {
|
|
340
|
+
if (typeof this._config.tls === 'boolean') {
|
|
341
|
+
bunOptions.tls = this._config.tls;
|
|
342
|
+
} else {
|
|
343
|
+
bunOptions.tls = this._config.tls;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Set up onclose handler for reconnection
|
|
348
|
+
bunOptions.onclose = (err: Error | null) => {
|
|
349
|
+
this._handleClose(err ?? undefined);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
this._sql = new BunSQL(bunOptions);
|
|
353
|
+
this._initialized = true;
|
|
354
|
+
// Note: _connected remains false until we verify the connection with a query
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Warms the connection by executing a test query.
|
|
359
|
+
* This establishes the actual TCP connection and verifies it's working.
|
|
360
|
+
*/
|
|
361
|
+
private async _warmConnection(): Promise<void> {
|
|
362
|
+
if (this._closed || this._connected) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!this._sql) {
|
|
367
|
+
this._initializeSql();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Execute a test query to establish the TCP connection
|
|
371
|
+
// If this fails, the error will propagate to the caller
|
|
372
|
+
await this._sql!`SELECT 1`;
|
|
373
|
+
this._connected = true;
|
|
374
|
+
this._stats.totalConnections++;
|
|
375
|
+
this._stats.lastConnectedAt = new Date();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Re-initializes the SQL client for reconnection.
|
|
380
|
+
* Used internally during the reconnection loop.
|
|
381
|
+
*/
|
|
382
|
+
private _reinitializeSql(): void {
|
|
383
|
+
this._initialized = false;
|
|
384
|
+
this._connected = false;
|
|
385
|
+
this._initializeSql();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Handles connection close events.
|
|
390
|
+
*/
|
|
391
|
+
private _handleClose(error?: Error): void {
|
|
392
|
+
const wasConnected = this._connected;
|
|
393
|
+
this._connected = false;
|
|
394
|
+
this._stats.lastDisconnectedAt = new Date();
|
|
395
|
+
|
|
396
|
+
// Call user's onclose callback
|
|
397
|
+
this._config.onclose?.(error);
|
|
398
|
+
|
|
399
|
+
// Don't reconnect if explicitly closed OR if application is shutting down
|
|
400
|
+
if (this._closed || this._shuttingDown) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Check if reconnection is enabled
|
|
405
|
+
const reconnectConfig = mergeReconnectConfig(this._config.reconnect);
|
|
406
|
+
if (!reconnectConfig.enabled) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// If there's an error, check if it's retryable
|
|
411
|
+
// If there's NO error (graceful close), still attempt reconnection
|
|
412
|
+
if (error && !isRetryableError(error)) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Start reconnection if not already in progress
|
|
417
|
+
if (!this._reconnecting && wasConnected) {
|
|
418
|
+
this._startReconnect();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Starts the reconnection process.
|
|
424
|
+
*/
|
|
425
|
+
private _startReconnect(): void {
|
|
426
|
+
if (this._reconnecting || this._closed || this._shuttingDown) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
this._reconnecting = true;
|
|
431
|
+
this._reconnectPromise = this._reconnectLoop();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The main reconnection loop with exponential backoff.
|
|
436
|
+
*/
|
|
437
|
+
private async _reconnectLoop(): Promise<void> {
|
|
438
|
+
const config = mergeReconnectConfig(this._config.reconnect);
|
|
439
|
+
let attempt = 0;
|
|
440
|
+
let lastError: Error | undefined;
|
|
441
|
+
|
|
442
|
+
while (attempt < config.maxAttempts && !this._closed && !this._shuttingDown) {
|
|
443
|
+
this._stats.reconnectAttempts++;
|
|
444
|
+
this._stats.lastReconnectAttemptAt = new Date();
|
|
445
|
+
|
|
446
|
+
// Notify about reconnection attempt
|
|
447
|
+
this._config.onreconnect?.(attempt + 1);
|
|
448
|
+
|
|
449
|
+
// Calculate backoff delay
|
|
450
|
+
const delay = computeBackoff(attempt, config);
|
|
451
|
+
|
|
452
|
+
// Wait before attempting
|
|
453
|
+
await sleep(delay);
|
|
454
|
+
|
|
455
|
+
if (this._closed) {
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
// Close existing connection if any
|
|
461
|
+
if (this._sql) {
|
|
462
|
+
try {
|
|
463
|
+
await this._sql.close();
|
|
464
|
+
} catch {
|
|
465
|
+
// Ignore close errors
|
|
466
|
+
}
|
|
467
|
+
this._sql = null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Attempt to reconnect - reinitialize and warm the connection
|
|
471
|
+
this._reinitializeSql();
|
|
472
|
+
await this._warmConnection();
|
|
473
|
+
|
|
474
|
+
// Success!
|
|
475
|
+
this._reconnecting = false;
|
|
476
|
+
this._reconnectPromise = null;
|
|
477
|
+
this._config.onreconnected?.();
|
|
478
|
+
return;
|
|
479
|
+
} catch (error) {
|
|
480
|
+
lastError =
|
|
481
|
+
error instanceof Error
|
|
482
|
+
? error
|
|
483
|
+
: new PostgresError({
|
|
484
|
+
message: String(error),
|
|
485
|
+
});
|
|
486
|
+
this._stats.failedReconnects++;
|
|
487
|
+
attempt++;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// All attempts failed
|
|
492
|
+
this._reconnecting = false;
|
|
493
|
+
this._reconnectPromise = null;
|
|
494
|
+
|
|
495
|
+
// Only invoke callback if not explicitly closed/shutdown to avoid noisy/misleading callbacks
|
|
496
|
+
if (!this._closed && !this._shuttingDown) {
|
|
497
|
+
const finalError = new ReconnectFailedError({
|
|
498
|
+
attempts: attempt,
|
|
499
|
+
lastError,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
this._config.onreconnectfailed?.(finalError);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Ensures the client is initialized and returns the SQL instance.
|
|
508
|
+
* This is the synchronous version - use _ensureConnectedAsync when you can await.
|
|
509
|
+
*
|
|
510
|
+
* Note: This returns the SQL instance even if `_connected` is false because
|
|
511
|
+
* Bun's SQL uses lazy connections. The actual connection will be established
|
|
512
|
+
* on first query. Use this for synchronous access to the SQL instance.
|
|
513
|
+
*/
|
|
514
|
+
private _ensureConnected(): InstanceType<typeof BunSQL> {
|
|
515
|
+
if (this._closed) {
|
|
516
|
+
throw new ConnectionClosedError({
|
|
517
|
+
message: 'Client has been closed',
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!this._sql) {
|
|
522
|
+
throw new ConnectionClosedError({
|
|
523
|
+
message: 'SQL client not initialized',
|
|
524
|
+
wasReconnecting: this._reconnecting,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return this._sql;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Ensures the client is connected and returns the SQL instance.
|
|
533
|
+
* If reconnection is in progress, waits for it to complete.
|
|
534
|
+
* If connection hasn't been established yet, warms it first.
|
|
535
|
+
*/
|
|
536
|
+
private async _ensureConnectedAsync(): Promise<InstanceType<typeof BunSQL>> {
|
|
537
|
+
if (this._closed) {
|
|
538
|
+
throw new ConnectionClosedError({
|
|
539
|
+
message: 'Client has been closed',
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// If preconnect is in progress, wait for it
|
|
544
|
+
if (this._connectPromise) {
|
|
545
|
+
try {
|
|
546
|
+
await this._connectPromise;
|
|
547
|
+
} catch (err) {
|
|
548
|
+
this._connectPromise = null;
|
|
549
|
+
throw err;
|
|
550
|
+
}
|
|
551
|
+
this._connectPromise = null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// If reconnection is in progress, wait for it to complete
|
|
555
|
+
if (this._reconnecting && this._reconnectPromise) {
|
|
556
|
+
await this._reconnectPromise;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (!this._sql) {
|
|
560
|
+
throw new ConnectionClosedError({
|
|
561
|
+
message: 'SQL client not initialized',
|
|
562
|
+
wasReconnecting: false,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// If not yet connected, warm the connection
|
|
567
|
+
if (!this._connected) {
|
|
568
|
+
await this._warmConnection();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return this._sql;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Executes an operation with retry logic for retryable errors.
|
|
576
|
+
* Waits for reconnection if one is in progress.
|
|
577
|
+
*/
|
|
578
|
+
private async _executeWithRetry<T>(
|
|
579
|
+
operation: () => T | Promise<T>,
|
|
580
|
+
maxRetries: number = 3
|
|
581
|
+
): Promise<T> {
|
|
582
|
+
let lastError: Error | undefined;
|
|
583
|
+
|
|
584
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
585
|
+
try {
|
|
586
|
+
// Wait for preconnect if in progress
|
|
587
|
+
if (this._connectPromise) {
|
|
588
|
+
try {
|
|
589
|
+
await this._connectPromise;
|
|
590
|
+
} catch (err) {
|
|
591
|
+
this._connectPromise = null;
|
|
592
|
+
throw err;
|
|
593
|
+
}
|
|
594
|
+
this._connectPromise = null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Wait for reconnection if in progress
|
|
598
|
+
if (this._reconnecting && this._reconnectPromise) {
|
|
599
|
+
await this._reconnectPromise;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (!this._sql) {
|
|
603
|
+
throw new ConnectionClosedError({
|
|
604
|
+
message: 'SQL client not initialized',
|
|
605
|
+
wasReconnecting: this._reconnecting,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// If not yet connected, warm the connection
|
|
610
|
+
if (!this._connected) {
|
|
611
|
+
await this._warmConnection();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return await operation();
|
|
615
|
+
} catch (error) {
|
|
616
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
617
|
+
|
|
618
|
+
// If it's a retryable error and we have retries left, wait and retry
|
|
619
|
+
if (isRetryableError(error) && attempt < maxRetries) {
|
|
620
|
+
// Wait for reconnection to complete if it started
|
|
621
|
+
if (this._reconnecting && this._reconnectPromise) {
|
|
622
|
+
try {
|
|
623
|
+
await this._reconnectPromise;
|
|
624
|
+
} catch {
|
|
625
|
+
// Reconnection failed, will throw below
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
throw lastError;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Wait for the connection to be established.
|
|
640
|
+
* If the connection hasn't been established yet (lazy connection), this will
|
|
641
|
+
* warm the connection by executing a test query.
|
|
642
|
+
* If reconnection is in progress, waits for it to complete.
|
|
643
|
+
*
|
|
644
|
+
* @param timeoutMs - Optional timeout in milliseconds
|
|
645
|
+
* @throws {ConnectionClosedError} If the client has been closed or connection fails
|
|
646
|
+
*/
|
|
647
|
+
async waitForConnection(timeoutMs?: number): Promise<void> {
|
|
648
|
+
if (this._connected && this._sql) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (this._closed) {
|
|
653
|
+
throw new ConnectionClosedError({
|
|
654
|
+
message: 'Client has been closed',
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const connectOperation = async () => {
|
|
659
|
+
// Wait for preconnect if in progress
|
|
660
|
+
if (this._connectPromise) {
|
|
661
|
+
try {
|
|
662
|
+
await this._connectPromise;
|
|
663
|
+
} catch (err) {
|
|
664
|
+
this._connectPromise = null;
|
|
665
|
+
throw err;
|
|
666
|
+
}
|
|
667
|
+
this._connectPromise = null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Wait for reconnection if in progress
|
|
671
|
+
if (this._reconnecting && this._reconnectPromise) {
|
|
672
|
+
await this._reconnectPromise;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// If still not connected, warm the connection
|
|
676
|
+
if (!this._connected && this._sql) {
|
|
677
|
+
await this._warmConnection();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (!this._connected || !this._sql) {
|
|
681
|
+
throw new ConnectionClosedError({
|
|
682
|
+
message: 'Failed to establish connection',
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
if (timeoutMs !== undefined) {
|
|
688
|
+
let timerId: ReturnType<typeof setTimeout> | undefined;
|
|
689
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
690
|
+
timerId = setTimeout(
|
|
691
|
+
() =>
|
|
692
|
+
reject(
|
|
693
|
+
new QueryTimeoutError({
|
|
694
|
+
timeoutMs,
|
|
695
|
+
})
|
|
696
|
+
),
|
|
697
|
+
timeoutMs
|
|
698
|
+
);
|
|
699
|
+
});
|
|
700
|
+
try {
|
|
701
|
+
await Promise.race([connectOperation(), timeoutPromise]);
|
|
702
|
+
} finally {
|
|
703
|
+
if (timerId !== undefined) {
|
|
704
|
+
clearTimeout(timerId);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
} else {
|
|
708
|
+
await connectOperation();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Type for the callable PostgresClient that supports tagged template literals.
|
|
715
|
+
*/
|
|
716
|
+
export type CallablePostgresClient = PostgresClient & {
|
|
717
|
+
(strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Creates a PostgresClient that can be called as a tagged template literal.
|
|
722
|
+
*
|
|
723
|
+
* @param config - Connection configuration
|
|
724
|
+
* @returns A callable PostgresClient
|
|
725
|
+
*
|
|
726
|
+
* @internal
|
|
727
|
+
*/
|
|
728
|
+
export function createCallableClient(config?: string | PostgresConfig): CallablePostgresClient {
|
|
729
|
+
const client = new PostgresClient(config);
|
|
730
|
+
|
|
731
|
+
// Create a callable function that delegates to client.query
|
|
732
|
+
const callable = function (
|
|
733
|
+
strings: TemplateStringsArray,
|
|
734
|
+
...values: unknown[]
|
|
735
|
+
): Promise<unknown[]> {
|
|
736
|
+
return client.query(strings, ...values);
|
|
737
|
+
} as unknown as CallablePostgresClient;
|
|
738
|
+
|
|
739
|
+
// Copy all properties and methods from the client to the callable
|
|
740
|
+
Object.setPrototypeOf(callable, PostgresClient.prototype);
|
|
741
|
+
|
|
742
|
+
// Define properties that delegate to the client
|
|
743
|
+
Object.defineProperties(callable, {
|
|
744
|
+
connected: {
|
|
745
|
+
get: () => client.connected,
|
|
746
|
+
enumerable: true,
|
|
747
|
+
},
|
|
748
|
+
reconnecting: {
|
|
749
|
+
get: () => client.reconnecting,
|
|
750
|
+
enumerable: true,
|
|
751
|
+
},
|
|
752
|
+
shuttingDown: {
|
|
753
|
+
get: () => client.shuttingDown,
|
|
754
|
+
enumerable: true,
|
|
755
|
+
},
|
|
756
|
+
stats: {
|
|
757
|
+
get: () => client.stats,
|
|
758
|
+
enumerable: true,
|
|
759
|
+
},
|
|
760
|
+
raw: {
|
|
761
|
+
get: () => client.raw,
|
|
762
|
+
enumerable: true,
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Bind methods to the client
|
|
767
|
+
callable.query = client.query.bind(client);
|
|
768
|
+
callable.begin = client.begin.bind(client);
|
|
769
|
+
callable.reserve = client.reserve.bind(client);
|
|
770
|
+
callable.close = client.close.bind(client);
|
|
771
|
+
callable.shutdown = client.shutdown.bind(client);
|
|
772
|
+
callable.unsafe = client.unsafe.bind(client);
|
|
773
|
+
callable.waitForConnection = client.waitForConnection.bind(client);
|
|
774
|
+
|
|
775
|
+
return callable;
|
|
776
|
+
}
|