@vibeorm/adapter-pg 1.3.0 → 2.0.0-alpha.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/index.ts DELETED
@@ -1,469 +0,0 @@
1
- /**
2
- * @vibeorm/adapter-pg — node-postgres (pg) adapter for VibeORM.
3
- *
4
- * Uses the `pg` package's Pool for connection pooling with optional
5
- * named prepared statement caching for PostgreSQL plan reuse.
6
- */
7
-
8
- import type { DatabaseAdapter, QueryResult, TransactionOptions } from "@vibeorm/runtime";
9
- import { PgArray } from "@vibeorm/runtime";
10
-
11
- /**
12
- * Options for creating a pg adapter.
13
- * Either provide connection parameters or an existing Pool instance.
14
- */
15
- export type PgAdapterOptions = {
16
- /** PostgreSQL connection URL. Falls back to DATABASE_URL env var if not provided. */
17
- connectionString?: string;
18
- /** Maximum number of connections in the pool (default: 10). */
19
- max?: number;
20
- /**
21
- * Default statement timeout in milliseconds for all queries (default: none).
22
- *
23
- * Sets PostgreSQL's `statement_timeout` at the pool level via the pg driver's
24
- * `statement_timeout` option, so every query on every connection inherits it
25
- * automatically. Queries exceeding this duration are cancelled by PostgreSQL
26
- * with SQLSTATE 57014, which VibeORM surfaces as a `VibeTransientError` with
27
- * code `STATEMENT_TIMEOUT`.
28
- *
29
- * Transaction-level `timeout` (via `$transaction` options) overrides this
30
- * default for the duration of that transaction using `SET LOCAL statement_timeout`.
31
- *
32
- * Recommended: Set to a value appropriate for your workload (e.g. 30000 for
33
- * general use, 250–1000 for latency-sensitive OLTP services).
34
- */
35
- statementTimeout?: number;
36
- /**
37
- * Maximum time in milliseconds to wait for a connection from the pool (default: none).
38
- *
39
- * When set, pool checkout that exceeds this duration throws an error.
40
- * Prevents indefinite blocking when the pool is exhausted under load.
41
- *
42
- * Maps to pg Pool's `connectionTimeoutMillis` option.
43
- */
44
- connectionTimeout?: number;
45
- /**
46
- * Whether to use named prepared statements for read queries (default: false).
47
- *
48
- * When `true`, the adapter assigns a deterministic name to each unique SQL text,
49
- * creating named prepared statements that PostgreSQL caches execution plans for.
50
- * This saves ~0.1ms of planning time per query but can cause PostgreSQL to
51
- * switch to a "generic plan" after 5 executions, which may choose a worse
52
- * index for queries with range filters or skewed data distributions.
53
- *
54
- * When `false` (default), the adapter sends queries without a statement name.
55
- * PostgreSQL replans each execution using the actual parameter values, always
56
- * choosing the optimal index.
57
- *
58
- * Recommendation: Leave as `false` unless profiling shows planning overhead
59
- * is a bottleneck (rare — typically only for sub-millisecond queries at
60
- * very high throughput).
61
- */
62
- preparedStatements?: boolean;
63
- /**
64
- * Maximum number of entries in the prepared statement name cache (default: 1000).
65
- * Only used when `preparedStatements: true`.
66
- * Each unique SQL text gets a deterministic name for PostgreSQL plan caching.
67
- * LRU eviction when full.
68
- */
69
- stmtCacheMax?: number;
70
- } | {
71
- /** An existing pg Pool instance to use. */
72
- pool: PgPool;
73
- /**
74
- * Whether to use named prepared statements for read queries (default: false).
75
- * See the other overload for full documentation.
76
- */
77
- preparedStatements?: boolean;
78
- /**
79
- * Maximum number of entries in the prepared statement name cache (default: 1000).
80
- * Only used when `preparedStatements: true`.
81
- */
82
- stmtCacheMax?: number;
83
- };
84
-
85
- // ─── Minimal pg types (avoids hard dependency on @types/pg) ───────
86
-
87
- type PgPoolClient = {
88
- query(config: { text: string; values?: unknown[]; name?: string }): Promise<PgQueryResult>;
89
- query(text: string, values?: unknown[]): Promise<PgQueryResult>;
90
- release(): void;
91
- };
92
-
93
- type PgQueryResult = {
94
- rows: Record<string, unknown>[];
95
- rowCount: number | null;
96
- command: string;
97
- };
98
-
99
- type PgPool = {
100
- connect(): Promise<PgPoolClient>;
101
- query(config: { text: string; values?: unknown[]; name?: string }): Promise<PgQueryResult>;
102
- query(text: string, values?: unknown[]): Promise<PgQueryResult>;
103
- end(): Promise<void>;
104
- };
105
-
106
- // ─── Internal marker for array params ─────────────────────────────
107
-
108
- /**
109
- * Internal wrapper to tag a value as a PostgreSQL array parameter (for = ANY()).
110
- * This distinguishes array params from JSON array values so the adapter
111
- * can JSON.stringify JSON values without affecting array params.
112
- */
113
- class PgArrayParam {
114
- constructor(public readonly values: unknown[]) {}
115
- }
116
-
117
- // ─── Value Serialization ──────────────────────────────────────────
118
-
119
- /**
120
- * Pre-process query parameter values for the pg driver — ORM hot path.
121
- *
122
- * The pg driver auto-converts JS arrays to PostgreSQL array literals (`{1,2,3}`)
123
- * and JS objects to `[object Object]`. The ORM doesn't know which column a
124
- * value lands in (it doesn't carry typed-parameter hints), and for `json` /
125
- * `jsonb` columns it wants any JS object OR JS array passed through as JSON.
126
- * Therefore on the ORM path we JSON.stringify both objects and arrays —
127
- * scalar-list array values (`= ANY()` / `String[]` / `Int[]`) are already
128
- * tagged with `PgArrayParam` by `formatArrayParam` and unwrapped here without
129
- * stringification.
130
- *
131
- * Raw-query paths use `serializeParamsRaw` instead (see below) which does NOT
132
- * stringify plain JS arrays so that `db.$queryRawUnsafe('… WHERE "id" = ANY($1)',
133
- * [1, 2, 3])` actually matches rows.
134
- *
135
- * - PgArrayParam wrappers are unwrapped to raw arrays (for = ANY() queries
136
- * and for scalar-list array operations the ORM emits).
137
- * - Plain objects and arrays are JSON.stringify'd (for json/jsonb columns).
138
- * - Dates, Buffers, null, and primitives pass through unchanged.
139
- *
140
- * TODO: PgArrayParam nesting is not currently reachable through the public API,
141
- * so the unwrap is intentionally non-recursive. If PgArrayParam ever becomes
142
- * user-constructible and nestable inside other arrays/objects, make recursive.
143
- */
144
- function serializeParams(params: { values: unknown[] }): unknown[] {
145
- return params.values.map((v) => {
146
- // Unwrap any array-param wrapper — covers BOTH the adapter's internal
147
- // PgArrayParam (set by this adapter's formatArrayParam) AND the runtime's
148
- // PgArray marker. The runtime's `client.ts` normally unwraps PgArray into
149
- // PgArrayParam via `adapter.formatArrayParam(...)`, but recognising both
150
- // here is defence-in-depth — any future executor path that bypasses that
151
- // unwrap step would otherwise silently double-serialise the array as JSON.
152
- // Bug 3 — see .ai/bug-report-2026-05-24.md.
153
- if (v instanceof PgArrayParam || v instanceof PgArray) {
154
- return v.values;
155
- }
156
- // JSON.stringify plain objects and arrays for json/jsonb columns
157
- if (
158
- v !== null &&
159
- v !== undefined &&
160
- typeof v === "object" &&
161
- !(v instanceof Date) &&
162
- !Buffer.isBuffer(v) &&
163
- !(v instanceof Uint8Array)
164
- ) {
165
- return JSON.stringify(v);
166
- }
167
- return v;
168
- });
169
- }
170
-
171
- /**
172
- * Pre-process query parameter values for the pg driver — RAW QUERY path.
173
- *
174
- * Identical to `serializeParams` EXCEPT that plain JS arrays are passed through
175
- * to `pg` instead of being JSON.stringify'd. node-postgres natively converts a
176
- * JS array to a PostgreSQL array literal, which is what users expect when they
177
- * write raw queries like:
178
- *
179
- * db.$queryRawUnsafe('SELECT * FROM "User" WHERE "id" = ANY($1)', [1, 2, 3])
180
- * db.$queryRaw`SELECT * FROM "User" WHERE "id" = ANY(${[1, 2, 3]})`
181
- *
182
- * (Previously the adapter JSON.stringify'd the array, producing `"[1,2,3]"` —
183
- * a JSON string that PG could not match against any row.)
184
- *
185
- * Tradeoff: a user who wants to bind a JS array INTO a `json` / `jsonb` column
186
- * from a raw query must `JSON.stringify(...)` it themselves. Plain objects are
187
- * still auto-stringified here, so the common `{ foo: "bar" }` case continues
188
- * to work.
189
- */
190
- function serializeParamsRaw(params: { values: unknown[] }): unknown[] {
191
- return params.values.map((v) => {
192
- // Recognise both wrappers — see serializeParams for rationale (Bug 3).
193
- if (v instanceof PgArrayParam || v instanceof PgArray) {
194
- return v.values;
195
- }
196
- // Plain JS arrays pass through — pg converts to PG array literal natively.
197
- if (Array.isArray(v)) {
198
- return v;
199
- }
200
- // JSON.stringify plain non-array objects for json/jsonb columns.
201
- if (
202
- v !== null &&
203
- v !== undefined &&
204
- typeof v === "object" &&
205
- !(v instanceof Date) &&
206
- !Buffer.isBuffer(v) &&
207
- !(v instanceof Uint8Array)
208
- ) {
209
- return JSON.stringify(v);
210
- }
211
- return v;
212
- });
213
- }
214
-
215
- /**
216
- * Create a VibeORM database adapter using node-postgres (pg).
217
- *
218
- * @example
219
- * ```ts
220
- * import { pgAdapter } from "@vibeorm/adapter-pg";
221
- * import { VibeClient } from "./generated/vibeorm";
222
- *
223
- * // With connection string
224
- * const db = VibeClient({
225
- * adapter: pgAdapter({ connectionString: "postgres://...", max: 20 }),
226
- * });
227
- *
228
- * // With existing pg Pool
229
- * import { Pool } from "pg";
230
- * const pool = new Pool({ connectionString: "postgres://..." });
231
- * const db = VibeClient({
232
- * adapter: pgAdapter({ pool }),
233
- * });
234
- *
235
- * // With prepared statements enabled (for high-throughput local scenarios)
236
- * const db2 = VibeClient({
237
- * adapter: pgAdapter({ connectionString: "postgres://...", preparedStatements: true }),
238
- * });
239
- * ```
240
- */
241
- export function pgAdapter(options?: PgAdapterOptions): DatabaseAdapter {
242
- const USE_PREPARED = (options && "preparedStatements" in options ? options.preparedStatements : undefined) ?? false;
243
- const stmtCacheMax = (options && "stmtCacheMax" in options ? options.stmtCacheMax : undefined) ?? 1000;
244
-
245
- // LRU cache: SQL text -> deterministic prepared statement name
246
- const stmtNameCache = new Map<string, string>();
247
- let stmtCounter = 0;
248
-
249
- function getOrCreateStmtName(params: { text: string }): string {
250
- const { text } = params;
251
- let name = stmtNameCache.get(text);
252
- if (name) {
253
- // Move to end (most recently used)
254
- stmtNameCache.delete(text);
255
- stmtNameCache.set(text, name);
256
- return name;
257
- }
258
- name = `vibeorm_${stmtCounter++}`;
259
- if (stmtNameCache.size >= stmtCacheMax) {
260
- const oldestKey = stmtNameCache.keys().next().value;
261
- if (oldestKey !== undefined) stmtNameCache.delete(oldestKey);
262
- }
263
- stmtNameCache.set(text, name);
264
- return name;
265
- }
266
-
267
- let pool: PgPool | null = null;
268
- let ownsPool = false;
269
-
270
- function getPool(): PgPool {
271
- if (pool) return pool;
272
-
273
- // Dynamic import of pg to avoid bundling issues
274
- // eslint-disable-next-line @typescript-eslint/no-require-imports
275
- const pg = require("pg");
276
- const Pool = pg.Pool ?? pg.default?.Pool;
277
-
278
- if (options && "pool" in options && options.pool) {
279
- pool = options.pool;
280
- ownsPool = false;
281
- } else {
282
- const connectionString =
283
- (options && "connectionString" in options ? options.connectionString : undefined) ??
284
- process.env.DATABASE_URL;
285
- const max = (options && "max" in options ? options.max : undefined) ?? 10;
286
- const statementTimeout =
287
- options && "statementTimeout" in options ? options.statementTimeout : undefined;
288
- const connectionTimeout =
289
- options && "connectionTimeout" in options ? options.connectionTimeout : undefined;
290
-
291
- const poolConfig: Record<string, unknown> = {
292
- connectionString,
293
- max,
294
- };
295
-
296
- // Set statement_timeout at the pool level — every connection inherits it
297
- if (statementTimeout !== undefined) {
298
- poolConfig.statement_timeout = Number(statementTimeout);
299
- }
300
-
301
- // Set connectionTimeoutMillis — pool.connect() throws if exceeded
302
- if (connectionTimeout !== undefined) {
303
- poolConfig.connectionTimeoutMillis = Number(connectionTimeout);
304
- }
305
-
306
- pool = new Pool(poolConfig) as PgPool;
307
- ownsPool = true;
308
- }
309
-
310
- return pool;
311
- }
312
-
313
- /**
314
- * Execute a parameterized query through a pg client or pool.
315
- * - preparedStatements=true: uses named statements for plan caching
316
- * - preparedStatements=false: uses unnamed statements (replanned each time)
317
- */
318
- function buildQueryConfig(params: { text: string; values: unknown[] }): { text: string; values: unknown[]; name?: string } {
319
- const serialized = serializeParams({ values: params.values });
320
- if (USE_PREPARED) {
321
- return {
322
- name: getOrCreateStmtName({ text: params.text }),
323
- text: params.text,
324
- values: serialized,
325
- };
326
- }
327
- return {
328
- text: params.text,
329
- values: serialized,
330
- };
331
- }
332
-
333
- /**
334
- * Build a client-scoped adapter (already inside a transaction). Nested
335
- * `.transaction(fn)` calls open SAVEPOINTs on the same client.
336
- *
337
- * `savepointCounter` is shared by reference across every adapter created
338
- * for one top-level transaction so sibling and deeply-nested savepoints
339
- * never collide (Bug #11 — counter previously reset at each nesting level,
340
- * so two sibling sub-transactions both got `vibeorm_sp_0`).
341
- */
342
- function createClientAdapter(params: {
343
- client: PgPoolClient;
344
- savepointCounter: { n: number };
345
- }): DatabaseAdapter {
346
- const { client, savepointCounter } = params;
347
-
348
- return {
349
- async execute(execParams) {
350
- const config = buildQueryConfig(execParams);
351
- const result = await client.query(config);
352
- return result.rows;
353
- },
354
-
355
- async executeUnsafe(execParams) {
356
- const values = execParams.values ? serializeParamsRaw({ values: execParams.values }) : undefined;
357
- const result = await client.query(execParams.text, values);
358
- const affectedRows = result.rowCount ?? 0;
359
- return { rows: result.rows, affectedRows };
360
- },
361
-
362
- async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>): Promise<T> {
363
- // Nested transactions use unique SAVEPOINTs.
364
- const spName = `vibeorm_sp_${savepointCounter.n++}`;
365
- await client.query(`SAVEPOINT ${spName}`);
366
- try {
367
- const nestedAdapter = createClientAdapter({ client, savepointCounter });
368
- const result = await fn(nestedAdapter);
369
- await client.query(`RELEASE SAVEPOINT ${spName}`);
370
- return result;
371
- } catch (err) {
372
- // Bug #7: best-effort rollback — never mask the original error if
373
- // `ROLLBACK TO SAVEPOINT` itself fails (e.g. the client died).
374
- try { await client.query(`ROLLBACK TO SAVEPOINT ${spName}`); } catch { /* rollback best-effort */ }
375
- throw err;
376
- }
377
- },
378
-
379
- async connect() {
380
- // No-op for client-level adapter (already connected)
381
- },
382
-
383
- async disconnect() {
384
- // No-op for client-level adapter (pool manages connections)
385
- },
386
-
387
- formatArrayParam(values: unknown[]): unknown {
388
- // Wrap in PgArrayParam so serializeParams knows not to JSON.stringify it
389
- return new PgArrayParam(values);
390
- },
391
- };
392
- }
393
-
394
- function isolationLevelToSql(params: { level: NonNullable<TransactionOptions["isolationLevel"]> }): string {
395
- switch (params.level) {
396
- case "ReadCommitted": return "READ COMMITTED";
397
- case "RepeatableRead": return "REPEATABLE READ";
398
- case "Serializable": return "SERIALIZABLE";
399
- }
400
- }
401
-
402
- const adapter: DatabaseAdapter = {
403
- async execute(params) {
404
- const p = getPool();
405
- const config = buildQueryConfig(params);
406
- const result = await p.query(config);
407
- return result.rows;
408
- },
409
-
410
- async executeUnsafe(params) {
411
- const p = getPool();
412
- const values = params.values ? serializeParamsRaw({ values: params.values }) : undefined;
413
- const result = await p.query(params.text, values);
414
- const affectedRows = result.rowCount ?? 0;
415
- return { rows: result.rows, affectedRows };
416
- },
417
-
418
- async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>, options?: TransactionOptions): Promise<T> {
419
- const p = getPool();
420
- const client = await p.connect();
421
- // Per top-level transaction: one shared savepoint counter for all
422
- // sibling/nested SAVEPOINTs (Bug #11).
423
- const savepointCounter = { n: 0 };
424
- try {
425
- const isolation = options?.isolationLevel
426
- ? ` ISOLATION LEVEL ${isolationLevelToSql({ level: options.isolationLevel })}`
427
- : "";
428
- await client.query(`BEGIN${isolation}`);
429
-
430
- if (options?.timeout) {
431
- await client.query(`SET LOCAL statement_timeout = ${Number(options.timeout)}`);
432
- }
433
-
434
- const txAdapter = createClientAdapter({ client, savepointCounter });
435
- const result = await fn(txAdapter);
436
- await client.query("COMMIT");
437
- return result;
438
- } catch (err) {
439
- // Bug #7: best-effort ROLLBACK — never mask the original error if
440
- // ROLLBACK itself fails (e.g. the connection was killed). pg's pool
441
- // auto-discards the client on `release()` if its state is broken.
442
- try { await client.query("ROLLBACK"); } catch { /* rollback best-effort */ }
443
- throw err;
444
- } finally {
445
- client.release();
446
- }
447
- },
448
-
449
- async connect() {
450
- const p = getPool();
451
- const client = await p.connect();
452
- client.release();
453
- },
454
-
455
- async disconnect() {
456
- if (pool && ownsPool) {
457
- await pool.end();
458
- pool = null;
459
- }
460
- },
461
-
462
- formatArrayParam(values: unknown[]): unknown {
463
- // Wrap in PgArrayParam so serializeParams knows not to JSON.stringify it
464
- return new PgArrayParam(values);
465
- },
466
- };
467
-
468
- return adapter;
469
- }