@agentuity/postgres 1.0.15 → 1.0.17

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/client.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { SQL as BunSQL, type SQLQuery, type SQL } from 'bun';
2
- import type { PostgresConfig, ConnectionStats, TransactionOptions, ReserveOptions } from './types';
2
+ import type {
3
+ PostgresConfig,
4
+ ConnectionStats,
5
+ TransactionOptions,
6
+ ReserveOptions,
7
+ UnsafeQueryResult,
8
+ } from './types';
3
9
  import {
4
10
  ConnectionClosedError,
5
11
  PostgresError,
@@ -12,6 +18,73 @@ import { computeBackoff, sleep, mergeReconnectConfig } from './reconnect';
12
18
  import { Transaction, ReservedConnection } from './transaction';
13
19
  import { registerClient, unregisterClient } from './registry';
14
20
  import { injectSslMode } from './tls';
21
+ import { isMutationStatement } from './mutation';
22
+
23
+ /**
24
+ * Creates a lazy thenable with format locking for safe query execution.
25
+ *
26
+ * The returned object implements the {@link UnsafeQueryResult} interface: it is
27
+ * thenable (`.then()`, `.catch()`, `.finally()`) and exposes a `.values()`
28
+ * method for array-format results. Execution is deferred until the first
29
+ * consumption method is called.
30
+ *
31
+ * **Format locking:** The first access (`.then()` or `.values()`) determines
32
+ * the result format for the lifetime of the thenable. Attempting the other
33
+ * format afterwards throws an error, preventing accidental duplicate execution.
34
+ *
35
+ * @param makeExecutor - Factory that creates the execution promise.
36
+ * Called with `true` for array format (`.values()`) or `false` for row-object
37
+ * format (`.then()`).
38
+ * @returns A lazy thenable with `.values()` support
39
+ *
40
+ * @internal Exported for use by `@agentuity/drizzle` — not part of the public API.
41
+ */
42
+ export function createThenable(
43
+ makeExecutor: (useValues: boolean) => Promise<unknown>
44
+ ): UnsafeQueryResult {
45
+ let started: Promise<unknown> | null = null;
46
+ let executionMode: boolean | null = null; // tracks useValues for first call
47
+
48
+ const startExecution = (useValues: boolean): Promise<unknown> => {
49
+ if (started) {
50
+ if (executionMode !== useValues) {
51
+ throw new Error(
52
+ `Cannot access .${useValues ? 'values()' : 'then()'} after .${!useValues ? 'values()' : 'then()'} ` +
53
+ 'on the same query result. The result format is locked to the first access mode ' +
54
+ 'to prevent duplicate query execution. Create a new unsafeQuery() call for a different format.'
55
+ );
56
+ }
57
+ return started;
58
+ }
59
+ executionMode = useValues;
60
+ started = makeExecutor(useValues);
61
+ return started;
62
+ };
63
+
64
+ return new Proxy({} as UnsafeQueryResult, {
65
+ get(_target, prop) {
66
+ if (prop === 'then') {
67
+ return <TResult1 = unknown, TResult2 = never>(
68
+ onfulfilled?: ((value: unknown) => TResult1 | PromiseLike<TResult1>) | null,
69
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null
70
+ ): Promise<TResult1 | TResult2> => startExecution(false).then(onfulfilled, onrejected);
71
+ }
72
+ if (prop === 'catch') {
73
+ return <TResult = never>(
74
+ onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null
75
+ ): Promise<unknown | TResult> => startExecution(false).catch(onrejected);
76
+ }
77
+ if (prop === 'finally') {
78
+ return (onfinally?: (() => void) | null): Promise<unknown> =>
79
+ startExecution(false).finally(onfinally ?? undefined);
80
+ }
81
+ if (prop === 'values') {
82
+ return () => startExecution(true);
83
+ }
84
+ return undefined;
85
+ },
86
+ });
87
+ }
15
88
 
16
89
  /**
17
90
  * Bun SQL options for PostgreSQL connections.
@@ -149,8 +222,23 @@ export class PostgresClient {
149
222
  * ```
150
223
  */
151
224
  query(strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]> {
225
+ // Reconstruct query shape for mutation detection.
226
+ // Space as separator is safe — doesn't affect SQL keyword detection.
227
+ const queryShape = strings.join(' ');
228
+ const mutation = isMutationStatement(queryShape);
229
+
152
230
  return this._executeWithRetry(async () => {
153
231
  const sql = await this._ensureConnectedAsync();
232
+ if (mutation) {
233
+ // Use sql.begin() callback API for pool-safe transactions.
234
+ // Bun requires this instead of manual BEGIN/COMMIT when max > 1,
235
+ // because sql.begin() reserves a specific connection for the
236
+ // transaction. Manual BEGIN via sql.unsafe('BEGIN') would throw
237
+ // ERR_POSTGRES_UNSAFE_TRANSACTION on pooled connections.
238
+ return sql.begin(async (tx) => {
239
+ return tx(strings, ...values) as unknown as unknown[];
240
+ });
241
+ }
154
242
  return sql(strings, ...values);
155
243
  });
156
244
  }
@@ -179,6 +267,13 @@ export class PostgresClient {
179
267
  // This ensures _warmConnection() updates connection stats before we proceed
180
268
  const sql = await this._ensureConnectedAsync();
181
269
 
270
+ // Reserve a dedicated connection from the pool. Bun requires either
271
+ // sql.begin(callback), sql.reserve(), or max:1 for transactions.
272
+ // Since this API returns a Transaction object for manual commit/rollback,
273
+ // we need a reserved connection to guarantee all queries hit the same
274
+ // connection as the BEGIN.
275
+ const reserved = await sql.reserve();
276
+
182
277
  // Build BEGIN statement with options
183
278
  let beginStatement = 'BEGIN';
184
279
 
@@ -198,10 +293,15 @@ export class PostgresClient {
198
293
  beginStatement += ' NOT DEFERRABLE';
199
294
  }
200
295
 
201
- // Execute BEGIN
202
- const connection = await sql.unsafe(beginStatement);
203
-
204
- return new Transaction(sql, connection);
296
+ // Execute BEGIN on the reserved connection
297
+ try {
298
+ const connection = await reserved.unsafe(beginStatement);
299
+ return new Transaction(reserved, connection);
300
+ } catch (error) {
301
+ // Release the reserved connection if BEGIN fails
302
+ reserved.release();
303
+ throw error;
304
+ }
205
305
  }
206
306
 
207
307
  /**
@@ -278,6 +378,79 @@ export class PostgresClient {
278
378
  return sql.unsafe(query);
279
379
  }
280
380
 
381
+ /**
382
+ * Execute a raw SQL query with automatic retry and transaction wrapping for mutations.
383
+ *
384
+ * Unlike {@link unsafe}, this method:
385
+ * - Automatically retries on retryable errors (connection drops, resets)
386
+ * - Wraps mutation statements in transactions for safe retry
387
+ * - Returns a thenable with `.values()` support matching Bun's SQLQuery interface
388
+ *
389
+ * Detected mutation types: INSERT, UPDATE, DELETE, COPY, TRUNCATE, MERGE, CALL, DO.
390
+ * EXPLAIN queries are never wrapped (read-only analysis).
391
+ *
392
+ * For SELECT queries, retries without transaction wrapping (idempotent).
393
+ * For mutations, wraps in BEGIN/COMMIT so PostgreSQL auto-rolls back
394
+ * uncommitted transactions on connection drop, making retry safe.
395
+ *
396
+ * **⚠️ COMMIT Uncertainty Window:**
397
+ * If the connection drops after the server processes COMMIT but before the client
398
+ * receives confirmation (~<1ms window), changes ARE committed but the client sees
399
+ * a failure. Retry will then duplicate the mutation. This is an inherent limitation
400
+ * of retry-based approaches. Use application-level idempotency (e.g., unique
401
+ * constraints with ON CONFLICT) for critical operations.
402
+ *
403
+ * **⚠️ Result Format Locking:**
404
+ * Each unsafeQuery() result can only be consumed via ONE access pattern — either
405
+ * `await result` (row objects) or `await result.values()` (arrays), not both.
406
+ * Attempting the second pattern throws an error to prevent accidental duplicate execution.
407
+ *
408
+ * @param query - The raw SQL query string
409
+ * @param params - Optional query parameters
410
+ * @returns A thenable that resolves to rows (objects) or arrays via `.values()`
411
+ *
412
+ * @see https://github.com/agentuity/sdk/issues/911
413
+ *
414
+ * @example
415
+ * ```typescript
416
+ * // INSERT with safe retry
417
+ * const rows = await client.unsafeQuery('INSERT INTO items (name) VALUES ($1) RETURNING *', ['test']);
418
+ *
419
+ * // SELECT with retry (no transaction overhead)
420
+ * const items = await client.unsafeQuery('SELECT * FROM items WHERE id = $1', [42]);
421
+ *
422
+ * // Get raw arrays via .values()
423
+ * const arrays = await client.unsafeQuery('SELECT id, name FROM items').values();
424
+ * ```
425
+ */
426
+ unsafeQuery(query: string, params?: unknown[]): UnsafeQueryResult {
427
+ if (isMutationStatement(query)) {
428
+ // Use sql.begin() callback API for pool-safe transactions.
429
+ // Bun requires this instead of manual BEGIN/COMMIT when max > 1.
430
+ // sql.begin() auto-COMMITs on success and auto-ROLLBACKs on error.
431
+ const makeTransactionalExecutor = (useValues: boolean) =>
432
+ this._executeWithRetry(async () => {
433
+ const raw = this._ensureConnected();
434
+ return raw.begin(async (tx) => {
435
+ const q = params ? tx.unsafe(query, params) : tx.unsafe(query);
436
+ return useValues ? await q.values() : await q;
437
+ });
438
+ });
439
+
440
+ return createThenable(makeTransactionalExecutor);
441
+ }
442
+
443
+ // Non-mutation: plain retry without transaction overhead
444
+ const makeExecutor = (useValues: boolean) =>
445
+ this._executeWithRetry(async () => {
446
+ const raw = this._ensureConnected();
447
+ const q = params ? raw.unsafe(query, params) : raw.unsafe(query);
448
+ return useValues ? q.values() : q;
449
+ });
450
+
451
+ return createThenable(makeExecutor);
452
+ }
453
+
281
454
  /**
282
455
  * Registers signal handlers to detect application shutdown.
283
456
  * When shutdown is detected, reconnection is disabled.
@@ -335,10 +508,12 @@ export class PostgresClient {
335
508
  if (this._config.username) bunOptions.username = this._config.username;
336
509
  if (this._config.password) bunOptions.password = this._config.password;
337
510
  if (this._config.database) bunOptions.database = this._config.database;
511
+ if (this._config.path) bunOptions.path = this._config.path;
338
512
  if (this._config.max) bunOptions.max = this._config.max;
339
513
  if (this._config.idleTimeout !== undefined) bunOptions.idleTimeout = this._config.idleTimeout;
340
514
  if (this._config.connectionTimeout !== undefined)
341
515
  bunOptions.connectionTimeout = this._config.connectionTimeout;
516
+ if (this._config.maxLifetime !== undefined) bunOptions.maxLifetime = this._config.maxLifetime;
342
517
 
343
518
  // Handle TLS configuration
344
519
  if (this._config.tls !== undefined) {
@@ -349,6 +524,23 @@ export class PostgresClient {
349
524
  }
350
525
  }
351
526
 
527
+ // Postgres client runtime configuration (search_path, statement_timeout, etc.)
528
+ if (this._config.connection) bunOptions.connection = this._config.connection;
529
+
530
+ // Default to unnamed prepared statements (prepare: false) to prevent
531
+ // "prepared statement did not exist" errors when backend connections
532
+ // rotate (e.g., connection poolers, hot reloads, server restarts).
533
+ // See: https://github.com/agentuity/sdk/issues/1005
534
+ bunOptions.prepare = this._config.prepare ?? false;
535
+
536
+ // BigInt handling for integers outside i32 range
537
+ if (this._config.bigint !== undefined) bunOptions.bigint = this._config.bigint;
538
+
539
+ // Set up onconnect handler
540
+ if (this._config.onconnect) {
541
+ bunOptions.onconnect = this._config.onconnect;
542
+ }
543
+
352
544
  // Set up onclose handler for reconnection
353
545
  bunOptions.onclose = (err: Error | null) => {
354
546
  this._handleClose(err ?? undefined);
@@ -735,6 +927,7 @@ export class PostgresClient {
735
927
  */
736
928
  export type CallablePostgresClient = PostgresClient & {
737
929
  (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
930
+ unsafeQuery(query: string, params?: unknown[]): UnsafeQueryResult;
738
931
  };
739
932
 
740
933
  /**
@@ -790,6 +983,7 @@ export function createCallableClient(config?: string | PostgresConfig): Callable
790
983
  callable.close = client.close.bind(client);
791
984
  callable.shutdown = client.shutdown.bind(client);
792
985
  callable.unsafe = client.unsafe.bind(client);
986
+ callable.unsafeQuery = client.unsafeQuery.bind(client);
793
987
  callable.waitForConnection = client.waitForConnection.bind(client);
794
988
  callable.executeWithRetry = client.executeWithRetry.bind(client);
795
989
 
package/src/index.ts CHANGED
@@ -35,7 +35,12 @@
35
35
  export { postgres, default } from './postgres';
36
36
 
37
37
  // Client class for advanced usage
38
- export { PostgresClient, createCallableClient, type CallablePostgresClient } from './client';
38
+ export {
39
+ PostgresClient,
40
+ createCallableClient,
41
+ createThenable,
42
+ type CallablePostgresClient,
43
+ } from './client';
39
44
 
40
45
  // Pool class for pg.Pool-based connections
41
46
  export { PostgresPool, Pool, createPool } from './pool';
@@ -49,6 +54,9 @@ export { patchBunSQL, isPatched, SQL } from './patch';
49
54
  // TLS utilities
50
55
  export { injectSslMode } from './tls';
51
56
 
57
+ // Mutation detection utility
58
+ export { isMutationStatement } from './mutation';
59
+
52
60
  // Types
53
61
  export type {
54
62
  PostgresConfig,
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Strips leading whitespace and SQL comments (block and line) from a query string.
3
+ * Returns the remaining query text starting at the first non-comment token.
4
+ *
5
+ * Note: This regex does NOT support nested block comments. Use
6
+ * {@link stripLeadingComments} for full nested comment support.
7
+ */
8
+ export const LEADING_COMMENTS_RE = /^(?:\s+|\/\*[\s\S]*?\*\/|--[^\n]*\n)*/;
9
+
10
+ /**
11
+ * Strips leading whitespace and SQL comments from a query string.
12
+ * Supports nested block comments.
13
+ * Returns the remaining query text starting at the first non-comment token.
14
+ */
15
+ function stripLeadingComments(query: string): string {
16
+ let i = 0;
17
+ const len = query.length;
18
+ while (i < len) {
19
+ const ch = query[i]!;
20
+ // Skip whitespace
21
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
22
+ i++;
23
+ continue;
24
+ }
25
+ // Skip line comment: -- ...\n
26
+ if (ch === '-' && i + 1 < len && query[i + 1] === '-') {
27
+ i += 2;
28
+ while (i < len && query[i] !== '\n') i++;
29
+ if (i < len) i++; // skip newline
30
+ continue;
31
+ }
32
+ // Skip block comment with nesting: /* ... /* ... */ ... */
33
+ if (ch === '/' && i + 1 < len && query[i + 1] === '*') {
34
+ let commentDepth = 1;
35
+ i += 2;
36
+ while (i < len && commentDepth > 0) {
37
+ if (query[i] === '/' && i + 1 < len && query[i + 1] === '*') {
38
+ commentDepth++;
39
+ i += 2;
40
+ } else if (query[i] === '*' && i + 1 < len && query[i + 1] === '/') {
41
+ commentDepth--;
42
+ i += 2;
43
+ } else {
44
+ i++;
45
+ }
46
+ }
47
+ continue;
48
+ }
49
+ break;
50
+ }
51
+ return query.substring(i);
52
+ }
53
+
54
+ /** Regex matching the first keyword of a mutation statement. */
55
+ const MUTATION_KEYWORD_RE = /^(INSERT|UPDATE|DELETE|COPY|TRUNCATE|MERGE|CALL|DO)\b/i;
56
+
57
+ /**
58
+ * Determines whether a SQL query is a mutation that requires transaction
59
+ * wrapping for safe retry.
60
+ *
61
+ * Detected mutation types: INSERT, UPDATE, DELETE, COPY, TRUNCATE, MERGE,
62
+ * CALL, DO. EXPLAIN queries are never wrapped (read-only analysis, even
63
+ * when the explained statement is a mutation like `EXPLAIN INSERT INTO ...`).
64
+ *
65
+ * Mutation statements wrapped in a transaction can be safely retried because
66
+ * PostgreSQL guarantees that uncommitted transactions are rolled back when
67
+ * the connection drops. This prevents:
68
+ * - Duplicate rows from retried INSERTs
69
+ * - Double-applied changes from retried UPDATEs (e.g., counter increments)
70
+ * - Repeated side effects from retried DELETEs (e.g., cascade triggers)
71
+ *
72
+ * Handles three patterns:
73
+ * 1. Direct mutations: `INSERT INTO ...`, `UPDATE ... SET`, `DELETE FROM ...`,
74
+ * `COPY ...`, `TRUNCATE ...`, `MERGE ...`, `CALL ...`, `DO ...`
75
+ * (with optional leading comments/whitespace)
76
+ * 2. CTE mutations: `WITH cte AS (...) INSERT|UPDATE|DELETE|... ...` — scans
77
+ * past the WITH clause by tracking parenthesis depth to skip CTE
78
+ * subexpressions, then checks if the first top-level DML keyword is a
79
+ * mutation. The scanner treats single-quoted strings, double-quoted
80
+ * identifiers, dollar-quoted strings, line comments (--), and block
81
+ * comments (including nested) as atomic regions so parentheses inside
82
+ * them do not corrupt depth tracking.
83
+ * 3. Multi-statement queries: `SELECT 1; INSERT INTO items VALUES (1)` —
84
+ * scans past semicolons at depth 0 to find mutation keywords in
85
+ * subsequent statements.
86
+ *
87
+ * @see https://github.com/agentuity/sdk/issues/911
88
+ */
89
+ export function isMutationStatement(query: string): boolean {
90
+ // Strip leading whitespace and SQL comments (supports nested block comments)
91
+ const stripped = stripLeadingComments(query);
92
+
93
+ // EXPLAIN never mutates (even EXPLAIN INSERT INTO...)
94
+ if (/^EXPLAIN\b/i.test(stripped)) return false;
95
+
96
+ // Fast path: direct mutation statement
97
+ if (MUTATION_KEYWORD_RE.test(stripped)) {
98
+ return true;
99
+ }
100
+
101
+ // Fast path: no CTE prefix and no multi-statement separator → not a mutation
102
+ if (!/^WITH\s/i.test(stripped) && !stripped.includes(';')) {
103
+ return false;
104
+ }
105
+
106
+ // Full scan: walk the entire query character-by-character.
107
+ // Track parenthesis depth and check for mutation keywords at depth 0.
108
+ // This handles both CTE queries (WITH ... AS (...) DML ...) and
109
+ // multi-statement queries (SELECT ...; INSERT ...).
110
+ let depth = 0;
111
+ let i = 0;
112
+ const len = stripped.length;
113
+
114
+ while (i < len) {
115
+ const ch = stripped[i]!;
116
+
117
+ // ── Skip atomic regions (at any depth) ──────────────────────
118
+ // These regions may contain parentheses that must not affect depth.
119
+
120
+ // Single-quoted string: 'it''s a (test)'
121
+ if (ch === "'") {
122
+ i++;
123
+ while (i < len) {
124
+ if (stripped[i] === "'") {
125
+ i++;
126
+ if (i < len && stripped[i] === "'") {
127
+ i++; // escaped '' → still inside string
128
+ } else {
129
+ break; // end of string
130
+ }
131
+ } else {
132
+ i++;
133
+ }
134
+ }
135
+ continue;
136
+ }
137
+
138
+ // Double-quoted identifier: "col(1)"
139
+ if (ch === '"') {
140
+ i++;
141
+ while (i < len) {
142
+ if (stripped[i] === '"') {
143
+ i++;
144
+ if (i < len && stripped[i] === '"') {
145
+ i++; // escaped "" → still inside identifier
146
+ } else {
147
+ break;
148
+ }
149
+ } else {
150
+ i++;
151
+ }
152
+ }
153
+ continue;
154
+ }
155
+
156
+ // Line comment: -- has (parens)\n
157
+ if (ch === '-' && i + 1 < len && stripped[i + 1] === '-') {
158
+ i += 2;
159
+ while (i < len && stripped[i] !== '\n') i++;
160
+ if (i < len) i++; // skip newline
161
+ continue;
162
+ }
163
+
164
+ // Block comment: /* has (parens) */ — supports nesting
165
+ if (ch === '/' && i + 1 < len && stripped[i + 1] === '*') {
166
+ let commentDepth = 1;
167
+ i += 2;
168
+ while (i < len && commentDepth > 0) {
169
+ if (stripped[i] === '/' && i + 1 < len && stripped[i + 1] === '*') {
170
+ commentDepth++;
171
+ i += 2;
172
+ } else if (stripped[i] === '*' && i + 1 < len && stripped[i + 1] === '/') {
173
+ commentDepth--;
174
+ i += 2;
175
+ } else {
176
+ i++;
177
+ }
178
+ }
179
+ continue;
180
+ }
181
+
182
+ // Dollar-quoted string: $$has (parens)$$ or $tag$...$tag$
183
+ if (ch === '$') {
184
+ let tagEnd = i + 1;
185
+ while (tagEnd < len && /[a-zA-Z0-9_]/.test(stripped[tagEnd]!)) tagEnd++;
186
+ if (tagEnd < len && stripped[tagEnd] === '$') {
187
+ const tag = stripped.substring(i, tagEnd + 1);
188
+ i = tagEnd + 1;
189
+ const closeIdx = stripped.indexOf(tag, i);
190
+ if (closeIdx !== -1) {
191
+ i = closeIdx + tag.length;
192
+ } else {
193
+ i = len; // unterminated — skip to end
194
+ }
195
+ continue;
196
+ }
197
+ // Not a dollar-quote tag, fall through
198
+ }
199
+
200
+ // ── Track parenthesis depth ─────────────────────────────────
201
+ if (ch === '(') {
202
+ depth++;
203
+ i++;
204
+ continue;
205
+ }
206
+ if (ch === ')') {
207
+ depth--;
208
+ i++;
209
+ continue;
210
+ }
211
+
212
+ // Only inspect keywords at top level (depth === 0)
213
+ if (depth === 0) {
214
+ // Skip whitespace at top level
215
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
216
+ i++;
217
+ continue;
218
+ }
219
+
220
+ // Skip semicolons and commas between CTEs or statements
221
+ if (ch === ';' || ch === ',') {
222
+ i++;
223
+ continue;
224
+ }
225
+
226
+ // Check for mutation keyword or skip other words
227
+ // (CTE names, AS, RECURSIVE, SELECT, WITH, etc.)
228
+ if (/\w/.test(ch)) {
229
+ const rest = stripped.substring(i);
230
+ if (MUTATION_KEYWORD_RE.test(rest)) {
231
+ return true;
232
+ }
233
+ // Skip past this word
234
+ while (i < len && /\w/.test(stripped[i]!)) {
235
+ i++;
236
+ }
237
+ continue;
238
+ }
239
+ }
240
+
241
+ i++;
242
+ }
243
+
244
+ return false;
245
+ }
@@ -122,6 +122,8 @@ export class Transaction {
122
122
  phase: 'commit',
123
123
  cause: error,
124
124
  });
125
+ } finally {
126
+ this._releaseConnection();
125
127
  }
126
128
  }
127
129
 
@@ -142,6 +144,19 @@ export class Transaction {
142
144
  phase: 'rollback',
143
145
  cause: error,
144
146
  });
147
+ } finally {
148
+ this._releaseConnection();
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Releases the underlying reserved connection back to the pool.
154
+ * Called automatically on commit or rollback. Safe to call multiple times.
155
+ */
156
+ private _releaseConnection(): void {
157
+ const sql = this._sql as unknown as { release?: () => void };
158
+ if (typeof sql.release === 'function') {
159
+ sql.release();
145
160
  }
146
161
  }
147
162
 
package/src/types.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import type pg from 'pg';
2
2
 
3
+ export type UnsafeQueryResult = {
4
+ then: Promise<unknown>['then'];
5
+ catch: Promise<unknown>['catch'];
6
+ finally: Promise<unknown>['finally'];
7
+ values: () => Promise<unknown>;
8
+ };
9
+
3
10
  /**
4
11
  * TLS configuration options for PostgreSQL connections.
5
12
  */
@@ -149,20 +156,51 @@ export interface PostgresConfig {
149
156
  username?: string;
150
157
 
151
158
  /**
152
- * Database password.
159
+ * Database password for authentication.
160
+ * Can be a string or a function that returns the password (sync or async).
161
+ * Using a function enables rotating credentials (e.g., AWS RDS IAM tokens,
162
+ * GCP Cloud SQL IAM authentication).
153
163
  */
154
- password?: string;
164
+ password?: string | (() => string | Promise<string>);
155
165
 
156
166
  /**
157
167
  * Database name.
158
168
  */
159
169
  database?: string;
160
170
 
171
+ /**
172
+ * Unix domain socket path for local PostgreSQL connections.
173
+ * Alternative to hostname/port for same-machine connections.
174
+ *
175
+ * @example '/var/run/postgresql/.s.PGSQL.5432'
176
+ */
177
+ path?: string;
178
+
161
179
  /**
162
180
  * TLS configuration.
163
181
  */
164
182
  tls?: TLSConfig | boolean;
165
183
 
184
+ /**
185
+ * PostgreSQL client runtime configuration parameters sent during
186
+ * connection startup.
187
+ *
188
+ * These correspond to PostgreSQL runtime configuration settings.
189
+ *
190
+ * @see https://www.postgresql.org/docs/current/runtime-config-client.html
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * {
195
+ * search_path: 'myapp,public',
196
+ * statement_timeout: '30s',
197
+ * application_name: 'my-service',
198
+ * timezone: 'UTC',
199
+ * }
200
+ * ```
201
+ */
202
+ connection?: Record<string, string | boolean | number>;
203
+
166
204
  /**
167
205
  * Maximum number of connections in the pool.
168
206
  * @default 10
@@ -181,6 +219,43 @@ export interface PostgresConfig {
181
219
  */
182
220
  idleTimeout?: number;
183
221
 
222
+ /**
223
+ * Maximum lifetime of a connection in seconds.
224
+ * After this time, the connection is closed and a new one is created.
225
+ * Set to `0` for no maximum lifetime.
226
+ *
227
+ * This is useful in pooled environments to prevent stale connections
228
+ * and coordinate with connection pooler behavior.
229
+ *
230
+ * @default 0 (no maximum lifetime)
231
+ */
232
+ maxLifetime?: number;
233
+
234
+ /**
235
+ * Whether to use named prepared statements.
236
+ *
237
+ * When `true`, Bun's SQL driver caches named prepared statements on the
238
+ * server for better performance with repeated queries.
239
+ *
240
+ * When `false`, queries use unnamed prepared statements that are parsed
241
+ * fresh each time. This is required when using connection poolers like
242
+ * PGBouncer (in transaction mode) or Supavisor, where the backend
243
+ * connection may change between queries, invalidating cached statements.
244
+ *
245
+ * @default false
246
+ */
247
+ prepare?: boolean;
248
+
249
+ /**
250
+ * Whether to return large integers as BigInt instead of strings.
251
+ *
252
+ * When `true`, integers outside the i32 range are returned as `BigInt`.
253
+ * When `false`, they are returned as strings.
254
+ *
255
+ * @default false
256
+ */
257
+ bigint?: boolean;
258
+
184
259
  /**
185
260
  * Reconnection configuration.
186
261
  */
@@ -199,6 +274,12 @@ export interface PostgresConfig {
199
274
  */
200
275
  preconnect?: boolean;
201
276
 
277
+ /**
278
+ * Callback invoked when a connection attempt completes.
279
+ * Receives an Error on failure, or null on success.
280
+ */
281
+ onconnect?: (error: Error | null) => void;
282
+
202
283
  /**
203
284
  * Callback invoked when the connection is closed.
204
285
  */