@vibeorm/adapter-pg 1.0.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/README.md +56 -0
- package/package.json +42 -0
- package/src/index.ts +325 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @vibeorm/adapter-pg
|
|
2
|
+
|
|
3
|
+
`node-postgres` adapter for VibeORM. Use VibeORM with the `pg` driver on Bun or Node.js.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @vibeorm/adapter-pg pg
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { pgAdapter } from "@vibeorm/adapter-pg";
|
|
15
|
+
import { VibeClient } from "./generated/vibeorm/index.ts";
|
|
16
|
+
|
|
17
|
+
const db = VibeClient({
|
|
18
|
+
adapter: pgAdapter({ connectionString: "postgres://user:pass@localhost:5432/mydb" }),
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Using an existing pool
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import pg from "pg";
|
|
26
|
+
import { pgAdapter } from "@vibeorm/adapter-pg";
|
|
27
|
+
|
|
28
|
+
const pool = new pg.Pool({ connectionString: "postgres://..." });
|
|
29
|
+
|
|
30
|
+
const adapter = pgAdapter({ pool });
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
When you pass an existing pool, the adapter will **not** call `pool.end()` on disconnect — you manage the pool lifecycle.
|
|
34
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// Create a new pool
|
|
39
|
+
type PgAdapterOptions = {
|
|
40
|
+
connectionString?: string; // Connection string (defaults to DATABASE_URL env)
|
|
41
|
+
max?: number; // Pool size (default: 10)
|
|
42
|
+
preparedStatements?: boolean; // Enable prepared statement caching (default: false)
|
|
43
|
+
stmtCacheMax?: number; // Max cached prepared statements (default: 1000)
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Or use an existing pool
|
|
47
|
+
type PgAdapterOptions = {
|
|
48
|
+
pool: PgPool; // Existing pg.Pool instance
|
|
49
|
+
preparedStatements?: boolean;
|
|
50
|
+
stmtCacheMax?: number;
|
|
51
|
+
};
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
[MIT](../../LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vibeorm/adapter-pg",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "node-postgres adapter for VibeORM — use VibeORM with pg on Bun or Node.js",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"orm",
|
|
8
|
+
"adapter",
|
|
9
|
+
"pg",
|
|
10
|
+
"node-postgres",
|
|
11
|
+
"postgresql",
|
|
12
|
+
"typescript"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"default": "./src/index.ts",
|
|
18
|
+
"types": "./src/index.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/vibeorm/vibeorm.git",
|
|
27
|
+
"directory": "packages/adapter-pg"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/vibeorm/vibeorm/tree/master/packages/adapter-pg",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/vibeorm/vibeorm/issues"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@vibeorm/runtime": "workspace:*"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"pg": ">=8"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
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 } 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
|
+
* Whether to use named prepared statements for read queries (default: false).
|
|
21
|
+
*
|
|
22
|
+
* When `true`, the adapter assigns a deterministic name to each unique SQL text,
|
|
23
|
+
* creating named prepared statements that PostgreSQL caches execution plans for.
|
|
24
|
+
* This saves ~0.1ms of planning time per query but can cause PostgreSQL to
|
|
25
|
+
* switch to a "generic plan" after 5 executions, which may choose a worse
|
|
26
|
+
* index for queries with range filters or skewed data distributions.
|
|
27
|
+
*
|
|
28
|
+
* When `false` (default), the adapter sends queries without a statement name.
|
|
29
|
+
* PostgreSQL replans each execution using the actual parameter values, always
|
|
30
|
+
* choosing the optimal index.
|
|
31
|
+
*
|
|
32
|
+
* Recommendation: Leave as `false` unless profiling shows planning overhead
|
|
33
|
+
* is a bottleneck (rare — typically only for sub-millisecond queries at
|
|
34
|
+
* very high throughput).
|
|
35
|
+
*/
|
|
36
|
+
preparedStatements?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Maximum number of entries in the prepared statement name cache (default: 1000).
|
|
39
|
+
* Only used when `preparedStatements: true`.
|
|
40
|
+
* Each unique SQL text gets a deterministic name for PostgreSQL plan caching.
|
|
41
|
+
* LRU eviction when full.
|
|
42
|
+
*/
|
|
43
|
+
stmtCacheMax?: number;
|
|
44
|
+
} | {
|
|
45
|
+
/** An existing pg Pool instance to use. */
|
|
46
|
+
pool: PgPool;
|
|
47
|
+
/**
|
|
48
|
+
* Whether to use named prepared statements for read queries (default: false).
|
|
49
|
+
* See the other overload for full documentation.
|
|
50
|
+
*/
|
|
51
|
+
preparedStatements?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Maximum number of entries in the prepared statement name cache (default: 1000).
|
|
54
|
+
* Only used when `preparedStatements: true`.
|
|
55
|
+
*/
|
|
56
|
+
stmtCacheMax?: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ─── Minimal pg types (avoids hard dependency on @types/pg) ───────
|
|
60
|
+
|
|
61
|
+
type PgPoolClient = {
|
|
62
|
+
query(config: { text: string; values?: unknown[]; name?: string }): Promise<PgQueryResult>;
|
|
63
|
+
query(text: string, values?: unknown[]): Promise<PgQueryResult>;
|
|
64
|
+
release(): void;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type PgQueryResult = {
|
|
68
|
+
rows: Record<string, unknown>[];
|
|
69
|
+
rowCount: number | null;
|
|
70
|
+
command: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type PgPool = {
|
|
74
|
+
connect(): Promise<PgPoolClient>;
|
|
75
|
+
query(config: { text: string; values?: unknown[]; name?: string }): Promise<PgQueryResult>;
|
|
76
|
+
query(text: string, values?: unknown[]): Promise<PgQueryResult>;
|
|
77
|
+
end(): Promise<void>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ─── Internal marker for array params ─────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Internal wrapper to tag a value as a PostgreSQL array parameter (for = ANY()).
|
|
84
|
+
* This distinguishes array params from JSON array values so the adapter
|
|
85
|
+
* can JSON.stringify JSON values without affecting array params.
|
|
86
|
+
*/
|
|
87
|
+
class PgArrayParam {
|
|
88
|
+
constructor(public readonly values: unknown[]) {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Value Serialization ──────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pre-process query parameter values for the pg driver.
|
|
95
|
+
*
|
|
96
|
+
* The pg driver auto-converts JS arrays to PostgreSQL array literals (`{1,2,3}`)
|
|
97
|
+
* and JS objects to `[object Object]`. For json/jsonb columns, we need these
|
|
98
|
+
* serialized as JSON strings instead. Since the query builder is driver-agnostic
|
|
99
|
+
* and doesn't serialize values, we handle it here at the adapter boundary.
|
|
100
|
+
*
|
|
101
|
+
* - PgArrayParam wrappers are unwrapped to raw arrays (for = ANY() queries)
|
|
102
|
+
* - Plain objects and arrays are JSON.stringify'd (for json/jsonb columns)
|
|
103
|
+
* - Dates, Buffers, null, and primitives pass through unchanged
|
|
104
|
+
*/
|
|
105
|
+
function serializeParams(params: { values: unknown[] }): unknown[] {
|
|
106
|
+
return params.values.map((v) => {
|
|
107
|
+
// Unwrap tagged array params — these are for = ANY() and must remain as raw arrays
|
|
108
|
+
if (v instanceof PgArrayParam) {
|
|
109
|
+
return v.values;
|
|
110
|
+
}
|
|
111
|
+
// JSON.stringify plain objects and arrays for json/jsonb columns
|
|
112
|
+
if (
|
|
113
|
+
v !== null &&
|
|
114
|
+
v !== undefined &&
|
|
115
|
+
typeof v === "object" &&
|
|
116
|
+
!(v instanceof Date) &&
|
|
117
|
+
!Buffer.isBuffer(v) &&
|
|
118
|
+
!(v instanceof Uint8Array)
|
|
119
|
+
) {
|
|
120
|
+
return JSON.stringify(v);
|
|
121
|
+
}
|
|
122
|
+
return v;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a VibeORM database adapter using node-postgres (pg).
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```ts
|
|
131
|
+
* import { pgAdapter } from "@vibeorm/adapter-pg";
|
|
132
|
+
* import { VibeClient } from "./generated/vibeorm";
|
|
133
|
+
*
|
|
134
|
+
* // With connection string
|
|
135
|
+
* const db = VibeClient({
|
|
136
|
+
* adapter: pgAdapter({ connectionString: "postgres://...", max: 20 }),
|
|
137
|
+
* });
|
|
138
|
+
*
|
|
139
|
+
* // With existing pg Pool
|
|
140
|
+
* import { Pool } from "pg";
|
|
141
|
+
* const pool = new Pool({ connectionString: "postgres://..." });
|
|
142
|
+
* const db = VibeClient({
|
|
143
|
+
* adapter: pgAdapter({ pool }),
|
|
144
|
+
* });
|
|
145
|
+
*
|
|
146
|
+
* // With prepared statements enabled (for high-throughput local scenarios)
|
|
147
|
+
* const db2 = VibeClient({
|
|
148
|
+
* adapter: pgAdapter({ connectionString: "postgres://...", preparedStatements: true }),
|
|
149
|
+
* });
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export function pgAdapter(options?: PgAdapterOptions): DatabaseAdapter {
|
|
153
|
+
const USE_PREPARED = (options && "preparedStatements" in options ? options.preparedStatements : undefined) ?? false;
|
|
154
|
+
const stmtCacheMax = (options && "stmtCacheMax" in options ? options.stmtCacheMax : undefined) ?? 1000;
|
|
155
|
+
|
|
156
|
+
// LRU cache: SQL text -> deterministic prepared statement name
|
|
157
|
+
const stmtNameCache = new Map<string, string>();
|
|
158
|
+
let stmtCounter = 0;
|
|
159
|
+
|
|
160
|
+
function getOrCreateStmtName(params: { text: string }): string {
|
|
161
|
+
const { text } = params;
|
|
162
|
+
let name = stmtNameCache.get(text);
|
|
163
|
+
if (name) {
|
|
164
|
+
// Move to end (most recently used)
|
|
165
|
+
stmtNameCache.delete(text);
|
|
166
|
+
stmtNameCache.set(text, name);
|
|
167
|
+
return name;
|
|
168
|
+
}
|
|
169
|
+
name = `vibeorm_${stmtCounter++}`;
|
|
170
|
+
if (stmtNameCache.size >= stmtCacheMax) {
|
|
171
|
+
const oldestKey = stmtNameCache.keys().next().value;
|
|
172
|
+
if (oldestKey !== undefined) stmtNameCache.delete(oldestKey);
|
|
173
|
+
}
|
|
174
|
+
stmtNameCache.set(text, name);
|
|
175
|
+
return name;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let pool: PgPool | null = null;
|
|
179
|
+
let ownsPool = false;
|
|
180
|
+
|
|
181
|
+
function getPool(): PgPool {
|
|
182
|
+
if (pool) return pool;
|
|
183
|
+
|
|
184
|
+
// Dynamic import of pg to avoid bundling issues
|
|
185
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
186
|
+
const pg = require("pg");
|
|
187
|
+
const Pool = pg.Pool ?? pg.default?.Pool;
|
|
188
|
+
|
|
189
|
+
if (options && "pool" in options && options.pool) {
|
|
190
|
+
pool = options.pool;
|
|
191
|
+
ownsPool = false;
|
|
192
|
+
} else {
|
|
193
|
+
const connectionString =
|
|
194
|
+
(options && "connectionString" in options ? options.connectionString : undefined) ??
|
|
195
|
+
process.env.DATABASE_URL;
|
|
196
|
+
const max = (options && "max" in options ? options.max : undefined) ?? 10;
|
|
197
|
+
|
|
198
|
+
pool = new Pool({
|
|
199
|
+
connectionString,
|
|
200
|
+
max,
|
|
201
|
+
}) as PgPool;
|
|
202
|
+
ownsPool = true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return pool;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Execute a parameterized query through a pg client or pool.
|
|
210
|
+
* - preparedStatements=true: uses named statements for plan caching
|
|
211
|
+
* - preparedStatements=false: uses unnamed statements (replanned each time)
|
|
212
|
+
*/
|
|
213
|
+
function buildQueryConfig(params: { text: string; values: unknown[] }): { text: string; values: unknown[]; name?: string } {
|
|
214
|
+
const serialized = serializeParams({ values: params.values });
|
|
215
|
+
if (USE_PREPARED) {
|
|
216
|
+
return {
|
|
217
|
+
name: getOrCreateStmtName({ text: params.text }),
|
|
218
|
+
text: params.text,
|
|
219
|
+
values: serialized,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
text: params.text,
|
|
224
|
+
values: serialized,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function createClientAdapter(client: PgPoolClient): DatabaseAdapter {
|
|
229
|
+
return {
|
|
230
|
+
async execute(params) {
|
|
231
|
+
const config = buildQueryConfig(params);
|
|
232
|
+
const result = await client.query(config);
|
|
233
|
+
return result.rows;
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async executeUnsafe(params) {
|
|
237
|
+
const values = params.values ? serializeParams({ values: params.values }) : undefined;
|
|
238
|
+
const result = await client.query(params.text, values);
|
|
239
|
+
const affectedRows = result.rowCount ?? 0;
|
|
240
|
+
return { rows: result.rows, affectedRows };
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>): Promise<T> {
|
|
244
|
+
// Nested transactions use SAVEPOINTs
|
|
245
|
+
await client.query("SAVEPOINT vibeorm_nested");
|
|
246
|
+
try {
|
|
247
|
+
const nestedAdapter = createClientAdapter(client);
|
|
248
|
+
const result = await fn(nestedAdapter);
|
|
249
|
+
await client.query("RELEASE SAVEPOINT vibeorm_nested");
|
|
250
|
+
return result;
|
|
251
|
+
} catch (err) {
|
|
252
|
+
await client.query("ROLLBACK TO SAVEPOINT vibeorm_nested");
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
async connect() {
|
|
258
|
+
// No-op for client-level adapter (already connected)
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
async disconnect() {
|
|
262
|
+
// No-op for client-level adapter (pool manages connections)
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
formatArrayParam(values: unknown[]): unknown {
|
|
266
|
+
// Wrap in PgArrayParam so serializeParams knows not to JSON.stringify it
|
|
267
|
+
return new PgArrayParam(values);
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const adapter: DatabaseAdapter = {
|
|
273
|
+
async execute(params) {
|
|
274
|
+
const p = getPool();
|
|
275
|
+
const config = buildQueryConfig(params);
|
|
276
|
+
const result = await p.query(config);
|
|
277
|
+
return result.rows;
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
async executeUnsafe(params) {
|
|
281
|
+
const p = getPool();
|
|
282
|
+
const values = params.values ? serializeParams({ values: params.values }) : undefined;
|
|
283
|
+
const result = await p.query(params.text, values);
|
|
284
|
+
const affectedRows = result.rowCount ?? 0;
|
|
285
|
+
return { rows: result.rows, affectedRows };
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>): Promise<T> {
|
|
289
|
+
const p = getPool();
|
|
290
|
+
const client = await p.connect();
|
|
291
|
+
try {
|
|
292
|
+
await client.query("BEGIN");
|
|
293
|
+
const txAdapter = createClientAdapter(client);
|
|
294
|
+
const result = await fn(txAdapter);
|
|
295
|
+
await client.query("COMMIT");
|
|
296
|
+
return result;
|
|
297
|
+
} catch (err) {
|
|
298
|
+
await client.query("ROLLBACK");
|
|
299
|
+
throw err;
|
|
300
|
+
} finally {
|
|
301
|
+
client.release();
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
async connect() {
|
|
306
|
+
const p = getPool();
|
|
307
|
+
const client = await p.connect();
|
|
308
|
+
client.release();
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
async disconnect() {
|
|
312
|
+
if (pool && ownsPool) {
|
|
313
|
+
await pool.end();
|
|
314
|
+
pool = null;
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
formatArrayParam(values: unknown[]): unknown {
|
|
319
|
+
// Wrap in PgArrayParam so serializeParams knows not to JSON.stringify it
|
|
320
|
+
return new PgArrayParam(values);
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
return adapter;
|
|
325
|
+
}
|