@agentuity/postgres 1.0.14 → 1.0.16
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/dist/client.d.ts +68 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +173 -0
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/mutation.d.ts +42 -0
- package/dist/mutation.d.ts.map +1 -0
- package/dist/mutation.js +241 -0
- package/dist/mutation.js.map +1 -0
- package/dist/types.d.ts +76 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/client.ts +197 -1
- package/src/index.ts +9 -1
- package/src/mutation.ts +245 -0
- package/src/types.ts +83 -2
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 {
|
|
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,
|
package/src/mutation.ts
ADDED
|
@@ -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
|
+
}
|
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
|
*/
|