@vibeorm/adapter-pg 1.1.7 → 1.2.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 +97 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibeorm/adapter-pg",
3
- "version": "1.1.7",
3
+ "version": "1.2.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.7"
34
+ "@vibeorm/runtime": "1.2.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "pg": ">=8"
package/src/index.ts CHANGED
@@ -116,16 +116,29 @@ class PgArrayParam {
116
116
  // ─── Value Serialization ──────────────────────────────────────────
117
117
 
118
118
  /**
119
- * Pre-process query parameter values for the pg driver.
119
+ * Pre-process query parameter values for the pg driver — ORM hot path.
120
120
  *
121
121
  * 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.
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.
125
129
  *
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
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.
129
142
  */
130
143
  function serializeParams(params: { values: unknown[] }): unknown[] {
131
144
  return params.values.map((v) => {
@@ -148,6 +161,49 @@ function serializeParams(params: { values: unknown[] }): unknown[] {
148
161
  });
149
162
  }
150
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
+
151
207
  /**
152
208
  * Create a VibeORM database adapter using node-postgres (pg).
153
209
  *
@@ -266,34 +322,48 @@ export function pgAdapter(options?: PgAdapterOptions): DatabaseAdapter {
266
322
  };
267
323
  }
268
324
 
269
- function createClientAdapter(client: PgPoolClient): DatabaseAdapter {
270
- let savepointCounter = 0;
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;
271
339
 
272
340
  return {
273
- async execute(params) {
274
- const config = buildQueryConfig(params);
341
+ async execute(execParams) {
342
+ const config = buildQueryConfig(execParams);
275
343
  const result = await client.query(config);
276
344
  return result.rows;
277
345
  },
278
346
 
279
- async executeUnsafe(params) {
280
- const values = params.values ? serializeParams({ values: params.values }) : undefined;
281
- const result = await client.query(params.text, values);
347
+ async executeUnsafe(execParams) {
348
+ const values = execParams.values ? serializeParamsRaw({ values: execParams.values }) : undefined;
349
+ const result = await client.query(execParams.text, values);
282
350
  const affectedRows = result.rowCount ?? 0;
283
351
  return { rows: result.rows, affectedRows };
284
352
  },
285
353
 
286
354
  async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>): Promise<T> {
287
- // Nested transactions use unique SAVEPOINTs
288
- const spName = `vibeorm_sp_${savepointCounter++}`;
355
+ // Nested transactions use unique SAVEPOINTs.
356
+ const spName = `vibeorm_sp_${savepointCounter.n++}`;
289
357
  await client.query(`SAVEPOINT ${spName}`);
290
358
  try {
291
- const nestedAdapter = createClientAdapter(client);
359
+ const nestedAdapter = createClientAdapter({ client, savepointCounter });
292
360
  const result = await fn(nestedAdapter);
293
361
  await client.query(`RELEASE SAVEPOINT ${spName}`);
294
362
  return result;
295
363
  } catch (err) {
296
- await client.query(`ROLLBACK TO SAVEPOINT ${spName}`);
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 */ }
297
367
  throw err;
298
368
  }
299
369
  },
@@ -331,7 +401,7 @@ export function pgAdapter(options?: PgAdapterOptions): DatabaseAdapter {
331
401
 
332
402
  async executeUnsafe(params) {
333
403
  const p = getPool();
334
- const values = params.values ? serializeParams({ values: params.values }) : undefined;
404
+ const values = params.values ? serializeParamsRaw({ values: params.values }) : undefined;
335
405
  const result = await p.query(params.text, values);
336
406
  const affectedRows = result.rowCount ?? 0;
337
407
  return { rows: result.rows, affectedRows };
@@ -340,6 +410,9 @@ export function pgAdapter(options?: PgAdapterOptions): DatabaseAdapter {
340
410
  async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>, options?: TransactionOptions): Promise<T> {
341
411
  const p = getPool();
342
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 };
343
416
  try {
344
417
  const isolation = options?.isolationLevel
345
418
  ? ` ISOLATION LEVEL ${isolationLevelToSql({ level: options.isolationLevel })}`
@@ -350,12 +423,15 @@ export function pgAdapter(options?: PgAdapterOptions): DatabaseAdapter {
350
423
  await client.query(`SET LOCAL statement_timeout = ${Number(options.timeout)}`);
351
424
  }
352
425
 
353
- const txAdapter = createClientAdapter(client);
426
+ const txAdapter = createClientAdapter({ client, savepointCounter });
354
427
  const result = await fn(txAdapter);
355
428
  await client.query("COMMIT");
356
429
  return result;
357
430
  } catch (err) {
358
- await client.query("ROLLBACK");
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 */ }
359
435
  throw err;
360
436
  } finally {
361
437
  client.release();