@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.
Files changed (3) hide show
  1. package/README.md +56 -0
  2. package/package.json +42 -0
  3. 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
+ }