@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.
- package/package.json +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.
|
|
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.
|
|
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]`.
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
* -
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
|
133
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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(
|
|
274
|
-
const config = buildQueryConfig(
|
|
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(
|
|
280
|
-
const values =
|
|
281
|
-
const result = await client.query(
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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();
|