@vibeorm/adapter-pg 1.2.0 → 2.0.0-alpha.1

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