@vibeorm/adapter-pg 1.1.8 → 1.3.0

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/index.ts +107 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibeorm/adapter-pg",
3
- "version": "1.1.8",
3
+ "version": "1.3.0",
4
4
  "description": "node-postgres adapter for VibeORM — use VibeORM with pg on Bun or Node.js",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -31,7 +31,7 @@
31
31
  "url": "https://github.com/vibeorm/vibeorm/issues"
32
32
  },
33
33
  "dependencies": {
34
- "@vibeorm/runtime": "1.1.8"
34
+ "@vibeorm/runtime": "1.3.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "pg": ">=8"
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { DatabaseAdapter, QueryResult, TransactionOptions } from "@vibeorm/runtime";
9
+ import { PgArray } from "@vibeorm/runtime";
9
10
 
10
11
  /**
11
12
  * Options for creating a pg adapter.
@@ -116,21 +117,40 @@ class PgArrayParam {
116
117
  // ─── Value Serialization ──────────────────────────────────────────
117
118
 
118
119
  /**
119
- * Pre-process query parameter values for the pg driver.
120
+ * Pre-process query parameter values for the pg driver — ORM hot path.
120
121
  *
121
122
  * The pg driver auto-converts JS arrays to PostgreSQL array literals (`{1,2,3}`)
122
- * and JS objects to `[object Object]`. For json/jsonb columns, we need these
123
- * serialized as JSON strings instead. Since the query builder is driver-agnostic
124
- * and doesn't serialize values, we handle it here at the adapter boundary.
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.
125
130
  *
126
- * - PgArrayParam wrappers are unwrapped to raw arrays (for = ANY() queries)
127
- * - Plain objects and arrays are JSON.stringify'd (for json/jsonb columns)
128
- * - Dates, Buffers, null, and primitives pass through unchanged
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.
129
143
  */
130
144
  function serializeParams(params: { values: unknown[] }): unknown[] {
131
145
  return params.values.map((v) => {
132
- // Unwrap tagged array params — these are for = ANY() and must remain as raw arrays
133
- if (v instanceof PgArrayParam) {
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) {
134
154
  return v.values;
135
155
  }
136
156
  // JSON.stringify plain objects and arrays for json/jsonb columns
@@ -148,6 +168,50 @@ function serializeParams(params: { values: unknown[] }): unknown[] {
148
168
  });
149
169
  }
150
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
+
151
215
  /**
152
216
  * Create a VibeORM database adapter using node-postgres (pg).
153
217
  *
@@ -266,34 +330,48 @@ export function pgAdapter(options?: PgAdapterOptions): DatabaseAdapter {
266
330
  };
267
331
  }
268
332
 
269
- function createClientAdapter(client: PgPoolClient): DatabaseAdapter {
270
- let savepointCounter = 0;
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;
271
347
 
272
348
  return {
273
- async execute(params) {
274
- const config = buildQueryConfig(params);
349
+ async execute(execParams) {
350
+ const config = buildQueryConfig(execParams);
275
351
  const result = await client.query(config);
276
352
  return result.rows;
277
353
  },
278
354
 
279
- async executeUnsafe(params) {
280
- const values = params.values ? serializeParams({ values: params.values }) : undefined;
281
- const result = await client.query(params.text, values);
355
+ async executeUnsafe(execParams) {
356
+ const values = execParams.values ? serializeParamsRaw({ values: execParams.values }) : undefined;
357
+ const result = await client.query(execParams.text, values);
282
358
  const affectedRows = result.rowCount ?? 0;
283
359
  return { rows: result.rows, affectedRows };
284
360
  },
285
361
 
286
362
  async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>): Promise<T> {
287
- // Nested transactions use unique SAVEPOINTs
288
- const spName = `vibeorm_sp_${savepointCounter++}`;
363
+ // Nested transactions use unique SAVEPOINTs.
364
+ const spName = `vibeorm_sp_${savepointCounter.n++}`;
289
365
  await client.query(`SAVEPOINT ${spName}`);
290
366
  try {
291
- const nestedAdapter = createClientAdapter(client);
367
+ const nestedAdapter = createClientAdapter({ client, savepointCounter });
292
368
  const result = await fn(nestedAdapter);
293
369
  await client.query(`RELEASE SAVEPOINT ${spName}`);
294
370
  return result;
295
371
  } catch (err) {
296
- await client.query(`ROLLBACK TO SAVEPOINT ${spName}`);
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 */ }
297
375
  throw err;
298
376
  }
299
377
  },
@@ -331,7 +409,7 @@ export function pgAdapter(options?: PgAdapterOptions): DatabaseAdapter {
331
409
 
332
410
  async executeUnsafe(params) {
333
411
  const p = getPool();
334
- const values = params.values ? serializeParams({ values: params.values }) : undefined;
412
+ const values = params.values ? serializeParamsRaw({ values: params.values }) : undefined;
335
413
  const result = await p.query(params.text, values);
336
414
  const affectedRows = result.rowCount ?? 0;
337
415
  return { rows: result.rows, affectedRows };
@@ -340,6 +418,9 @@ export function pgAdapter(options?: PgAdapterOptions): DatabaseAdapter {
340
418
  async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>, options?: TransactionOptions): Promise<T> {
341
419
  const p = getPool();
342
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 };
343
424
  try {
344
425
  const isolation = options?.isolationLevel
345
426
  ? ` ISOLATION LEVEL ${isolationLevelToSql({ level: options.isolationLevel })}`
@@ -350,12 +431,15 @@ export function pgAdapter(options?: PgAdapterOptions): DatabaseAdapter {
350
431
  await client.query(`SET LOCAL statement_timeout = ${Number(options.timeout)}`);
351
432
  }
352
433
 
353
- const txAdapter = createClientAdapter(client);
434
+ const txAdapter = createClientAdapter({ client, savepointCounter });
354
435
  const result = await fn(txAdapter);
355
436
  await client.query("COMMIT");
356
437
  return result;
357
438
  } catch (err) {
358
- await client.query("ROLLBACK");
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 */ }
359
443
  throw err;
360
444
  } finally {
361
445
  client.release();