@vibeorm/adapter-bun 1.1.8 → 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 +282 -75
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibeorm/adapter-bun",
3
- "version": "1.1.8",
3
+ "version": "1.2.0",
4
4
  "description": "Bun-native database adapter for VibeORM using bun:sql",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -33,7 +33,7 @@
33
33
  "bun": ">=1.1.0"
34
34
  },
35
35
  "dependencies": {
36
- "@vibeorm/runtime": "1.1.8"
36
+ "@vibeorm/runtime": "1.2.0"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
package/src/index.ts CHANGED
@@ -30,13 +30,21 @@ export type BunAdapterOptions = {
30
30
  */
31
31
  statementTimeout?: number;
32
32
  /**
33
- * Maximum time in milliseconds to wait for a connection from the pool (default: none).
33
+ * Maximum time in milliseconds to wait when establishing a new connection (default: none).
34
34
  *
35
- * When set, connection acquisition that exceeds this duration will throw.
36
- * Prevents indefinite blocking when the pool is exhausted under load.
35
+ * Maps directly to bun:sql's `connectionTimeout` constructor option (which expects
36
+ * seconds — we convert ms → seconds for you). If a connection cannot be established
37
+ * within this duration, bun:sql throws `ERR_POSTGRES_CONNECTION_TIMEOUT`.
37
38
  *
38
- * Injected as `connect_timeout` in the PostgreSQL connection URL. Note that
39
- * bun:sql uses this for initial TCP connection establishment, not pool checkout.
39
+ * Note: this is for the initial TCP/handshake establishment, NOT for waiting on
40
+ * pool checkout once the pool is saturated.
41
+ *
42
+ * Historically this was injected as a `connect_timeout=…` URL parameter, but
43
+ * bun:sql forwards unrecognised URL parameters to PostgreSQL as runtime
44
+ * configuration settings, and `connect_timeout` is a libpq *client-side* option
45
+ * (not a server GUC), so PostgreSQL would error with
46
+ * `unrecognized configuration parameter "connect_timeout"`. The constructor
47
+ * option is the canonical way to set it.
40
48
  */
41
49
  connectionTimeout?: number;
42
50
  /**
@@ -97,20 +105,60 @@ export type BunAdapterOptions = {
97
105
 
98
106
  // ─── Internal bun:sql types ───────────────────────────────────────
99
107
 
108
+ /**
109
+ * A bun:sql "reserved" connection (single physical connection checked out
110
+ * from the pool). Used by the manual `BEGIN … COMMIT` transaction path.
111
+ *
112
+ * Note: a reserved connection has NO `.begin()` or `.reserve()` — any nested
113
+ * transaction must be implemented via SAVEPOINTs on the same connection.
114
+ */
100
115
  type SqlReserved = {
101
116
  (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
102
117
  unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
103
118
  release(): void;
104
119
  };
105
120
 
121
+ /**
122
+ * The transactional handle passed to the `sql.begin(callback)` callback.
123
+ * Modern bun:sql exposes `.savepoint(fn)` which creates a savepoint-scoped
124
+ * sub-transaction (the correct API for nested transactions). Calling
125
+ * `.begin()` here throws "cannot call begin inside a transaction use
126
+ * savepoint() instead" — so we always prefer `savepoint` when present.
127
+ *
128
+ * Older / minimal bun:sql versions may lack `.savepoint`, in which case we
129
+ * fall back to explicit `SAVEPOINT <name>` SQL via `.unsafe()`.
130
+ */
131
+ type SqlTransaction = {
132
+ (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
133
+ unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
134
+ savepoint?: <T>(fn: (tx: SqlTransaction) => Promise<T>) => Promise<T>;
135
+ };
136
+
106
137
  type SqlInstance = {
107
138
  (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
108
139
  unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
109
- begin<T>(fn: (tx: SqlInstance) => Promise<T>): Promise<T>;
140
+ begin<T>(fn: (tx: SqlTransaction) => Promise<T>): Promise<T>;
110
141
  reserve(): Promise<SqlReserved>;
111
142
  close(): Promise<void>;
112
143
  };
113
144
 
145
+ /**
146
+ * Anything that can execute SQL through bun:sql — pool, reserved connection,
147
+ * or in-transaction handle. Used by the unified adapter created at every
148
+ * nesting level.
149
+ */
150
+ type SqlSource = SqlInstance | SqlReserved | SqlTransaction;
151
+
152
+ /**
153
+ * Whether the SQL source is the outermost pool (can issue real BEGIN/COMMIT
154
+ * or use `sql.begin()`) or is already inside a transaction (must use
155
+ * SAVEPOINTs for nesting).
156
+ */
157
+ type ParentMode = "pool" | "tx";
158
+
159
+ /** Shared monotonic counter for savepoint naming across one top-level tx. */
160
+ type SavepointCounter = { n: number };
161
+
114
162
  /**
115
163
  * Create a VibeORM database adapter using Bun's built-in SQL driver.
116
164
  *
@@ -152,16 +200,19 @@ export function bunAdapter(options?: BunAdapterOptions): DatabaseAdapter {
152
200
  * Resolve the connection URL, injecting startup parameters as needed.
153
201
  * Falls back to DATABASE_URL env var when no explicit URL is provided.
154
202
  *
155
- * Injects via the PostgreSQL `options` startup parameter:
203
+ * Injects via the PostgreSQL `options` startup parameter (server-side runtime
204
+ * configuration; valid because `options` is a real Postgres startup-protocol
205
+ * parameter that the server parses for `-c key=value` settings):
156
206
  * - plan_cache_mode (unless "auto")
157
207
  * - statement_timeout (if configured)
158
208
  *
159
- * Injects as a URL parameter:
160
- * - connect_timeout (if configured, converted to seconds for PG)
209
+ * `connect_timeout` is NOT injected here — it's a libpq client-side option,
210
+ * not a server GUC, and bun:sql would forward it to the server as an
211
+ * unrecognised configuration parameter. We pass it via the bun:sql
212
+ * `connectionTimeout` constructor option instead (see getSql()).
161
213
  */
162
214
  function resolveConnectionUrl(): string | undefined {
163
215
  const STATEMENT_TIMEOUT = options?.statementTimeout;
164
- const CONNECTION_TIMEOUT = options?.connectionTimeout;
165
216
 
166
217
  // Build startup options string (for -c parameters)
167
218
  const startupParts: string[] = [];
@@ -172,29 +223,16 @@ export function bunAdapter(options?: BunAdapterOptions): DatabaseAdapter {
172
223
  startupParts.push(`-c statement_timeout=${Number(STATEMENT_TIMEOUT)}`);
173
224
  }
174
225
 
175
- const needsUrlMutation = startupParts.length > 0 || CONNECTION_TIMEOUT !== undefined;
226
+ const needsUrlMutation = startupParts.length > 0;
176
227
  if (!needsUrlMutation) return options?.url;
177
228
 
178
229
  const baseUrl = options?.url ?? process.env.DATABASE_URL;
179
230
  if (!baseUrl) return undefined;
180
231
 
181
- let url = baseUrl;
182
-
183
- // Inject startup options (-c params)
184
- if (startupParts.length > 0) {
185
- url = appendStartupOption({
186
- url,
187
- option: startupParts.join(" "),
188
- });
189
- }
190
-
191
- // Inject connect_timeout as a URL parameter (PG uses seconds)
192
- if (CONNECTION_TIMEOUT !== undefined) {
193
- const separator = url.includes("?") ? "&" : "?";
194
- url = `${url}${separator}connect_timeout=${Math.ceil(CONNECTION_TIMEOUT / 1000)}`;
195
- }
196
-
197
- return url;
232
+ return appendStartupOption({
233
+ url: baseUrl,
234
+ option: startupParts.join(" "),
235
+ });
198
236
  }
199
237
 
200
238
  function getSql(): SqlInstance {
@@ -215,6 +253,13 @@ export function bunAdapter(options?: BunAdapterOptions): DatabaseAdapter {
215
253
  // See: https://github.com/oven-sh/bun/issues/20294
216
254
  };
217
255
 
256
+ // Connection establishment timeout — bun:sql expects seconds.
257
+ // We accept milliseconds in our public API to stay consistent with pgAdapter
258
+ // and Node convention, then convert here.
259
+ if (options?.connectionTimeout !== undefined) {
260
+ sqlOptions.connectionTimeout = Math.max(1, Math.ceil(options.connectionTimeout / 1000));
261
+ }
262
+
218
263
  const connectionUrl = resolveConnectionUrl();
219
264
  if (connectionUrl) {
220
265
  sqlInstance = new SQL(connectionUrl, sqlOptions) as unknown as SqlInstance;
@@ -260,21 +305,86 @@ export function bunAdapter(options?: BunAdapterOptions): DatabaseAdapter {
260
305
  * Convert a JS array to a PostgreSQL array literal string `{val1,val2,...}`.
261
306
  * bun:sql's extended query protocol sends values as strings, so PostgreSQL
262
307
  * needs array parameters in its native array literal format.
308
+ *
309
+ * Element handling:
310
+ * - `null` / `undefined` → `NULL`
311
+ * - `number` / `bigint` / `boolean` → unquoted primitive
312
+ * - `Date` → ISO-8601 string (quoted+escaped) so PG can parse it as
313
+ * `timestamp[]` / `timestamptz[]`. Using the default `String(d)` would
314
+ * yield a non-ISO format like `Mon May 24 2026 …` that PG cannot parse.
315
+ * - `Buffer` / `Uint8Array` → not supported inside array literals; throw
316
+ * a clear error instead of silently producing `[object Object]`.
317
+ * - `string` → quoted with `"`/`\` escaping
318
+ * - other `object` → `JSON.stringify` then quoted+escaped (covers users
319
+ * putting plain objects into a `Json[]` / `Jsonb[]` scalar list).
263
320
  */
264
321
  function toPgArrayLiteral(arr: unknown[]): string {
322
+ const escape = (str: string): string =>
323
+ `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
324
+
265
325
  const escaped = arr.map((v) => {
266
326
  if (v === null || v === undefined) return "NULL";
267
- if (typeof v === "number" || typeof v === "bigint" || typeof v === "boolean") return String(v);
268
- const str = String(v);
269
- return `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
327
+ if (typeof v === "number" || typeof v === "bigint" || typeof v === "boolean") {
328
+ return String(v);
329
+ }
330
+ if (v instanceof Date) {
331
+ return escape(v.toISOString());
332
+ }
333
+ if (Buffer.isBuffer(v) || v instanceof Uint8Array) {
334
+ throw new Error(
335
+ "toPgArrayLiteral: Buffer/Uint8Array array elements are not supported"
336
+ );
337
+ }
338
+ if (typeof v === "string") {
339
+ return escape(v);
340
+ }
341
+ if (typeof v === "object") {
342
+ return escape(JSON.stringify(v));
343
+ }
344
+ return escape(String(v));
270
345
  });
271
346
  return `{${escaped.join(",")}}`;
272
347
  }
273
348
 
349
+ /**
350
+ * Normalise raw-path parameter values for bun:sql.
351
+ *
352
+ * bun:sql's extended query protocol sends params as strings, so a plain JS
353
+ * array binding cannot be auto-converted to a PG array literal by the driver.
354
+ * The ORM hot path wraps array params in `PgArray` and converts them via
355
+ * `formatArrayParam` before they reach the adapter — but raw queries
356
+ * (`$queryRawUnsafe`, `$queryRaw` template-literal, `$executeRaw*`) hand the
357
+ * user's array straight through.
358
+ *
359
+ * To make `db.$queryRawUnsafe('… WHERE "id" = ANY($1)', [1,2,3])` actually
360
+ * match rows, we convert any plain JS array element in the param list to a
361
+ * PG array literal here. Non-array values pass through unchanged so that
362
+ * bun:sql's native handling (numbers, strings, Dates, Buffers, plain objects
363
+ * for json columns when bun supports it) remains in effect.
364
+ */
365
+ function normalizeRawValues(values: unknown[] | undefined): unknown[] | undefined {
366
+ if (!values || values.length === 0) return values;
367
+ let mutated: unknown[] | null = null;
368
+ for (let i = 0; i < values.length; i++) {
369
+ const v = values[i];
370
+ if (Array.isArray(v)) {
371
+ if (!mutated) mutated = values.slice();
372
+ mutated[i] = toPgArrayLiteral(v);
373
+ }
374
+ }
375
+ return mutated ?? values;
376
+ }
377
+
274
378
  /**
275
379
  * Execute a query using the configured strategy.
276
380
  * - preparedStatements=true: uses synthetic tagged templates (named prepared stmts)
277
381
  * - preparedStatements=false: uses sql.unsafe() (replanned each time)
382
+ *
383
+ * Note: this is the ORM hot path. Scalar-list / `= ANY()` array values are
384
+ * already wrapped in `PgArray` by the query builder and converted to a PG
385
+ * array literal string via `formatArrayParam` before they reach here, so we
386
+ * intentionally do NOT touch raw JS arrays here (that would corrupt JSON
387
+ * column writes that genuinely pass a JS array as a JSON value).
278
388
  */
279
389
  async function executeQuery(sql: SqlInstance, params: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
280
390
  if (USE_PREPARED) {
@@ -294,14 +404,118 @@ export function bunAdapter(options?: BunAdapterOptions): DatabaseAdapter {
294
404
  }
295
405
  }
296
406
 
297
- function createAdapter(sql: SqlInstance): DatabaseAdapter {
407
+ /**
408
+ * Build the typed adapter facade over any bun:sql source — the pool, a
409
+ * reserved connection, or an in-transaction handle. The `parentMode`
410
+ * discriminator tells `.transaction()` whether a nested call should issue
411
+ * a real BEGIN/COMMIT (or `sql.begin()`) at the pool level, or open a
412
+ * SAVEPOINT on the current connection.
413
+ *
414
+ * `savepointCounter` is shared by reference across every adapter created
415
+ * for one top-level transaction, so sibling and deeply-nested transactions
416
+ * always get distinct savepoint names like `vibeorm_sp_0`, `vibeorm_sp_1`,
417
+ * `vibeorm_sp_2`, …
418
+ */
419
+ function createAdapter(params: {
420
+ sql: SqlSource;
421
+ parentMode: ParentMode;
422
+ savepointCounter: SavepointCounter;
423
+ }): DatabaseAdapter {
424
+ const { sql, parentMode, savepointCounter } = params;
425
+
426
+ async function txViaSavepoint<T>(innerFn: (txAdapter: DatabaseAdapter) => Promise<T>): Promise<T> {
427
+ // We're inside a transaction already — open a SAVEPOINT on this
428
+ // connection. Prefer the driver's native `tx.savepoint()` if exposed
429
+ // (modern bun:sql); else fall back to explicit `SAVEPOINT` SQL on
430
+ // either the in-tx handle (which lacks `.savepoint`) or a reserved
431
+ // connection holding a manual `BEGIN`.
432
+ const txSql = sql as SqlTransaction;
433
+ if (typeof txSql.savepoint === "function") {
434
+ return txSql.savepoint(async (sp) => {
435
+ const nestedAdapter = createAdapter({
436
+ sql: sp,
437
+ parentMode: "tx",
438
+ savepointCounter,
439
+ });
440
+ return innerFn(nestedAdapter);
441
+ });
442
+ }
443
+
444
+ // Fallback: explicit SAVEPOINT via unsafe(). The counter is shared by
445
+ // reference, so sibling/nested SAVEPOINTs never collide.
446
+ const spName = `vibeorm_sp_${savepointCounter.n++}`;
447
+ await txSql.unsafe(`SAVEPOINT ${spName}`);
448
+ try {
449
+ const nestedAdapter = createAdapter({
450
+ sql: txSql,
451
+ parentMode: "tx",
452
+ savepointCounter,
453
+ });
454
+ const result = await innerFn(nestedAdapter);
455
+ await txSql.unsafe(`RELEASE SAVEPOINT ${spName}`);
456
+ return result;
457
+ } catch (err) {
458
+ // Best-effort rollback — mirror pg adapter behaviour so we never
459
+ // mask the original error if the ROLLBACK itself fails.
460
+ try { await txSql.unsafe(`ROLLBACK TO SAVEPOINT ${spName}`); } catch { /* rollback best-effort */ }
461
+ throw err;
462
+ }
463
+ }
464
+
465
+ async function txViaPool<T>(
466
+ innerFn: (txAdapter: DatabaseAdapter) => Promise<T>,
467
+ options?: TransactionOptions
468
+ ): Promise<T> {
469
+ const pool = sql as SqlInstance;
470
+
471
+ // Fast path: no custom options — use native sql.begin() for best perf.
472
+ if (!options?.isolationLevel && !options?.timeout) {
473
+ return pool.begin(async (txSql: SqlTransaction) => {
474
+ const txAdapter = createAdapter({
475
+ sql: txSql,
476
+ parentMode: "tx",
477
+ savepointCounter,
478
+ });
479
+ return innerFn(txAdapter);
480
+ });
481
+ }
482
+
483
+ // Manual path: reserve a single connection for custom BEGIN options.
484
+ const reserved = await pool.reserve();
485
+ try {
486
+ const isolation = options.isolationLevel
487
+ ? ` ISOLATION LEVEL ${isolationLevelToSql({ level: options.isolationLevel })}`
488
+ : "";
489
+ await reserved.unsafe(`BEGIN${isolation}`);
490
+
491
+ if (options.timeout) {
492
+ await reserved.unsafe(`SET LOCAL statement_timeout = ${Number(options.timeout)}`);
493
+ }
494
+
495
+ const txAdapter = createAdapter({
496
+ sql: reserved,
497
+ parentMode: "tx",
498
+ savepointCounter,
499
+ });
500
+ const result = await innerFn(txAdapter);
501
+ await reserved.unsafe("COMMIT");
502
+ return result;
503
+ } catch (err) {
504
+ try { await reserved.unsafe("ROLLBACK"); } catch { /* rollback best-effort */ }
505
+ throw err;
506
+ } finally {
507
+ reserved.release();
508
+ }
509
+ }
510
+
298
511
  return {
299
- async execute(params) {
300
- return executeQuery(sql, params);
512
+ async execute(execParams) {
513
+ return executeQuery(sql as SqlInstance, execParams);
301
514
  },
302
515
 
303
- async executeUnsafe(params) {
304
- const result = await sql.unsafe(params.text, params.values);
516
+ async executeUnsafe(execParams) {
517
+ const values = normalizeRawValues(execParams.values);
518
+ const result = await sql.unsafe(execParams.text, values);
305
519
  const resultAny = result as unknown as Record<string, unknown>;
306
520
  let affectedRows: number;
307
521
  if (typeof resultAny.count === "number") {
@@ -318,46 +532,27 @@ export function bunAdapter(options?: BunAdapterOptions): DatabaseAdapter {
318
532
  },
319
533
 
320
534
  async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>, options?: TransactionOptions): Promise<T> {
321
- // Fast path: no custom options — use native sql.begin() for best performance
322
- if (!options?.isolationLevel && !options?.timeout) {
323
- return sql.begin(async (txSql: SqlInstance) => {
324
- const txAdapter = createAdapter(txSql);
325
- return fn(txAdapter);
326
- });
327
- }
328
-
329
- // Manual path: reserve a connection for custom BEGIN options
330
- const reserved = await sql.reserve();
331
- try {
332
- const isolation = options?.isolationLevel
333
- ? ` ISOLATION LEVEL ${isolationLevelToSql({ level: options.isolationLevel })}`
334
- : "";
335
- await reserved.unsafe(`BEGIN${isolation}`);
336
-
337
- if (options?.timeout) {
338
- await reserved.unsafe(`SET LOCAL statement_timeout = ${Number(options.timeout)}`);
339
- }
340
-
341
- const txAdapter = createAdapter(reserved as unknown as SqlInstance);
342
- const result = await fn(txAdapter);
343
- await reserved.unsafe("COMMIT");
344
- return result;
345
- } catch (err) {
346
- try { await reserved.unsafe("ROLLBACK"); } catch { /* rollback best-effort */ }
347
- throw err;
348
- } finally {
349
- reserved.release();
350
- }
535
+ return parentMode === "pool"
536
+ ? txViaPool(fn, options)
537
+ : txViaSavepoint(fn);
351
538
  },
352
539
 
353
540
  async connect() {
354
- const reserved = await sql.reserve();
355
- reserved.release();
541
+ // Only the pool-level adapter can reserve a fresh connection.
542
+ // Adapters scoped to a reserved/tx connection are already connected.
543
+ if (parentMode === "pool") {
544
+ const reserved = await (sql as SqlInstance).reserve();
545
+ reserved.release();
546
+ }
356
547
  },
357
548
 
358
549
  async disconnect() {
359
- await sql.close();
360
- sqlInstance = null;
550
+ if (parentMode === "pool") {
551
+ await (sql as SqlInstance).close();
552
+ sqlInstance = null;
553
+ }
554
+ // Inside a transaction `.disconnect()` is a no-op — the pool owns
555
+ // the connection lifecycle.
361
556
  },
362
557
 
363
558
  formatArrayParam(values: unknown[]): unknown {
@@ -366,22 +561,34 @@ export function bunAdapter(options?: BunAdapterOptions): DatabaseAdapter {
366
561
  };
367
562
  }
368
563
 
369
- // Create a lazy adapter that initializes the SQL connection on first use
564
+ // Create a lazy adapter that initializes the SQL connection on first use.
565
+ // Each top-level call lazily resolves the underlying `SqlInstance`. The
566
+ // `savepointCounter` for every top-level transaction is freshly created
567
+ // inside that transaction's own `txViaPool` / `txViaSavepoint` — at the
568
+ // pool level there's no shared counter to manage.
569
+ function poolAdapter(): DatabaseAdapter {
570
+ return createAdapter({
571
+ sql: getSql(),
572
+ parentMode: "pool",
573
+ savepointCounter: { n: 0 },
574
+ });
575
+ }
576
+
370
577
  const adapter: DatabaseAdapter = {
371
578
  async execute(params) {
372
- return createAdapter(getSql()).execute(params);
579
+ return poolAdapter().execute(params);
373
580
  },
374
581
 
375
582
  async executeUnsafe(params) {
376
- return createAdapter(getSql()).executeUnsafe(params);
583
+ return poolAdapter().executeUnsafe(params);
377
584
  },
378
585
 
379
586
  async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>, options?: TransactionOptions): Promise<T> {
380
- return createAdapter(getSql()).transaction(fn, options);
587
+ return poolAdapter().transaction(fn, options);
381
588
  },
382
589
 
383
590
  async connect() {
384
- return createAdapter(getSql()).connect();
591
+ return poolAdapter().connect();
385
592
  },
386
593
 
387
594
  async disconnect() {