@vibeorm/adapter-bun 1.3.0 → 2.0.0-alpha.10
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 +62 -49
- package/dist/connection-url.d.ts +37 -0
- package/dist/connection-url.d.ts.map +1 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +909 -0
- package/dist/index.js.map +18 -0
- package/dist/params.d.ts +59 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/savepoint-gate.d.ts +86 -0
- package/dist/savepoint-gate.d.ts.map +1 -0
- package/dist/savepoints.d.ts +22 -0
- package/dist/savepoints.d.ts.map +1 -0
- package/dist/template-cache.d.ts +76 -0
- package/dist/template-cache.d.ts.map +1 -0
- package/dist/transaction-budget.d.ts +144 -0
- package/dist/transaction-budget.d.ts.map +1 -0
- package/dist/transaction-sql.d.ts +62 -0
- package/dist/transaction-sql.d.ts.map +1 -0
- package/package.json +29 -19
- package/src/index.ts +0 -621
package/src/index.ts
DELETED
|
@@ -1,621 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @vibeorm/adapter-bun — Bun's native SQL adapter for VibeORM.
|
|
3
|
-
*
|
|
4
|
-
* Uses Bun's built-in `bun:sql` driver for high-performance PostgreSQL
|
|
5
|
-
* connections. Supports optional prepared statement caching via synthetic
|
|
6
|
-
* tagged templates.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { DatabaseAdapter, QueryResult, TransactionOptions } from "@vibeorm/runtime";
|
|
10
|
-
import { PgArray } from "@vibeorm/runtime";
|
|
11
|
-
|
|
12
|
-
export type BunAdapterOptions = {
|
|
13
|
-
/** PostgreSQL connection URL. Falls back to DATABASE_URL env var if not provided. */
|
|
14
|
-
url?: string;
|
|
15
|
-
/** Maximum number of connections in the pool (default: 10). */
|
|
16
|
-
max?: number;
|
|
17
|
-
/**
|
|
18
|
-
* Default statement timeout in milliseconds for all queries (default: none).
|
|
19
|
-
*
|
|
20
|
-
* Sets PostgreSQL's `statement_timeout` as a connection-level startup parameter,
|
|
21
|
-
* so every query on every connection inherits it automatically with zero per-query
|
|
22
|
-
* overhead. Queries exceeding this duration are cancelled by PostgreSQL with
|
|
23
|
-
* SQLSTATE 57014, which VibeORM surfaces as a `VibeTransientError` with code
|
|
24
|
-
* `STATEMENT_TIMEOUT`.
|
|
25
|
-
*
|
|
26
|
-
* Transaction-level `timeout` (via `$transaction` options) overrides this default
|
|
27
|
-
* for the duration of that transaction using `SET LOCAL statement_timeout`.
|
|
28
|
-
*
|
|
29
|
-
* Recommended: Set to a value appropriate for your workload (e.g. 30000 for
|
|
30
|
-
* general use, 250–1000 for latency-sensitive OLTP services).
|
|
31
|
-
*/
|
|
32
|
-
statementTimeout?: number;
|
|
33
|
-
/**
|
|
34
|
-
* Maximum time in milliseconds to wait when establishing a new connection (default: none).
|
|
35
|
-
*
|
|
36
|
-
* Maps directly to bun:sql's `connectionTimeout` constructor option (which expects
|
|
37
|
-
* seconds — we convert ms → seconds for you). If a connection cannot be established
|
|
38
|
-
* within this duration, bun:sql throws `ERR_POSTGRES_CONNECTION_TIMEOUT`.
|
|
39
|
-
*
|
|
40
|
-
* Note: this is for the initial TCP/handshake establishment, NOT for waiting on
|
|
41
|
-
* pool checkout once the pool is saturated.
|
|
42
|
-
*
|
|
43
|
-
* Historically this was injected as a `connect_timeout=…` URL parameter, but
|
|
44
|
-
* bun:sql forwards unrecognised URL parameters to PostgreSQL as runtime
|
|
45
|
-
* configuration settings, and `connect_timeout` is a libpq *client-side* option
|
|
46
|
-
* (not a server GUC), so PostgreSQL would error with
|
|
47
|
-
* `unrecognized configuration parameter "connect_timeout"`. The constructor
|
|
48
|
-
* option is the canonical way to set it.
|
|
49
|
-
*/
|
|
50
|
-
connectionTimeout?: number;
|
|
51
|
-
/**
|
|
52
|
-
* Whether to use named prepared statements for read queries (default: false).
|
|
53
|
-
*
|
|
54
|
-
* When `true`, the adapter caches SQL texts as synthetic tagged templates,
|
|
55
|
-
* creating named prepared statements that PostgreSQL caches execution plans for.
|
|
56
|
-
* This saves ~0.1ms of planning time per query but can cause PostgreSQL to
|
|
57
|
-
* switch to a "generic plan" after 5 executions, which may choose a worse
|
|
58
|
-
* index for queries with range filters or skewed data distributions.
|
|
59
|
-
*
|
|
60
|
-
* When `false` (default), the adapter uses `sql.unsafe()` which bypasses
|
|
61
|
-
* bun:sql's tagged template / named statement path. PostgreSQL replans each
|
|
62
|
-
* execution using the actual parameter values, always choosing the optimal index.
|
|
63
|
-
*
|
|
64
|
-
* Note: The bun:sql `prepare` constructor option is always left at its default
|
|
65
|
-
* (`true`). Setting `prepare: false` on the constructor triggers a bun:sql
|
|
66
|
-
* performance regression (~28ms per query on remote databases). Instead, we
|
|
67
|
-
* only control the execution path: `sql.unsafe()` vs tagged templates.
|
|
68
|
-
*
|
|
69
|
-
* Recommendation: Leave as `false` unless profiling shows planning overhead
|
|
70
|
-
* is a bottleneck (rare — typically only for sub-millisecond queries at
|
|
71
|
-
* very high throughput).
|
|
72
|
-
*/
|
|
73
|
-
preparedStatements?: boolean;
|
|
74
|
-
/**
|
|
75
|
-
* Maximum number of entries in the prepared statement cache (default: 1000).
|
|
76
|
-
* Only used when `preparedStatements: true`.
|
|
77
|
-
* Prevents unbounded memory growth in long-running processes.
|
|
78
|
-
* Each unique SQL text consumes one slot. LRU eviction when full.
|
|
79
|
-
*/
|
|
80
|
-
stmtCacheMax?: number;
|
|
81
|
-
/**
|
|
82
|
-
* Controls PostgreSQL's plan caching behavior for prepared statements
|
|
83
|
-
* (default: `"force_custom_plan"`).
|
|
84
|
-
*
|
|
85
|
-
* bun:sql uses the extended query protocol, which creates implicit prepared
|
|
86
|
-
* statements internally. After ~5 executions of the same query text,
|
|
87
|
-
* PostgreSQL may switch to a "generic plan" that ignores actual parameter
|
|
88
|
-
* values. This causes significant performance regressions for queries with
|
|
89
|
-
* range filters (`>=`, `<=`, `BETWEEN`), pattern matching (`LIKE`), or
|
|
90
|
-
* skewed data distributions — the planner picks sequential scans instead
|
|
91
|
-
* of index scans because it can't estimate selectivity without real values.
|
|
92
|
-
*
|
|
93
|
-
* - `"force_custom_plan"` (default): Always generates plans using actual
|
|
94
|
-
* parameter values. Costs ~0.1 ms extra planning per query but always
|
|
95
|
-
* picks the optimal index. Recommended for most workloads.
|
|
96
|
-
* - `"auto"`: PostgreSQL's default — switches to generic plans after ~5
|
|
97
|
-
* executions if the estimated cost is similar. Can cause 2-4× regressions
|
|
98
|
-
* on filtered COUNT / aggregate queries.
|
|
99
|
-
* - `"force_generic_plan"`: Always uses generic plans (not recommended).
|
|
100
|
-
*
|
|
101
|
-
* Injected via the PostgreSQL `options` startup parameter so it applies to
|
|
102
|
-
* every connection in the pool automatically. Requires PostgreSQL 12+.
|
|
103
|
-
*/
|
|
104
|
-
planCacheMode?: "auto" | "force_custom_plan" | "force_generic_plan";
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
// ─── Internal bun:sql types ───────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* A bun:sql "reserved" connection (single physical connection checked out
|
|
111
|
-
* from the pool). Used by the manual `BEGIN … COMMIT` transaction path.
|
|
112
|
-
*
|
|
113
|
-
* Note: a reserved connection has NO `.begin()` or `.reserve()` — any nested
|
|
114
|
-
* transaction must be implemented via SAVEPOINTs on the same connection.
|
|
115
|
-
*/
|
|
116
|
-
type SqlReserved = {
|
|
117
|
-
(strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
|
|
118
|
-
unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
|
|
119
|
-
release(): void;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* The transactional handle passed to the `sql.begin(callback)` callback.
|
|
124
|
-
* Modern bun:sql exposes `.savepoint(fn)` which creates a savepoint-scoped
|
|
125
|
-
* sub-transaction (the correct API for nested transactions). Calling
|
|
126
|
-
* `.begin()` here throws "cannot call begin inside a transaction use
|
|
127
|
-
* savepoint() instead" — so we always prefer `savepoint` when present.
|
|
128
|
-
*
|
|
129
|
-
* Older / minimal bun:sql versions may lack `.savepoint`, in which case we
|
|
130
|
-
* fall back to explicit `SAVEPOINT <name>` SQL via `.unsafe()`.
|
|
131
|
-
*/
|
|
132
|
-
type SqlTransaction = {
|
|
133
|
-
(strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
|
|
134
|
-
unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
|
|
135
|
-
savepoint?: <T>(fn: (tx: SqlTransaction) => Promise<T>) => Promise<T>;
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
type SqlInstance = {
|
|
139
|
-
(strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
|
|
140
|
-
unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
|
|
141
|
-
begin<T>(fn: (tx: SqlTransaction) => Promise<T>): Promise<T>;
|
|
142
|
-
reserve(): Promise<SqlReserved>;
|
|
143
|
-
close(): Promise<void>;
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Anything that can execute SQL through bun:sql — pool, reserved connection,
|
|
148
|
-
* or in-transaction handle. Used by the unified adapter created at every
|
|
149
|
-
* nesting level.
|
|
150
|
-
*/
|
|
151
|
-
type SqlSource = SqlInstance | SqlReserved | SqlTransaction;
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Whether the SQL source is the outermost pool (can issue real BEGIN/COMMIT
|
|
155
|
-
* or use `sql.begin()`) or is already inside a transaction (must use
|
|
156
|
-
* SAVEPOINTs for nesting).
|
|
157
|
-
*/
|
|
158
|
-
type ParentMode = "pool" | "tx";
|
|
159
|
-
|
|
160
|
-
/** Shared monotonic counter for savepoint naming across one top-level tx. */
|
|
161
|
-
type SavepointCounter = { n: number };
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Create a VibeORM database adapter using Bun's built-in SQL driver.
|
|
165
|
-
*
|
|
166
|
-
* @example
|
|
167
|
-
* ```ts
|
|
168
|
-
* import { bunAdapter } from "@vibeorm/adapter-bun";
|
|
169
|
-
* import { VibeClient } from "./generated/vibeorm";
|
|
170
|
-
*
|
|
171
|
-
* const db = VibeClient({
|
|
172
|
-
* adapter: bunAdapter({ url: "postgres://...", max: 10 }),
|
|
173
|
-
* });
|
|
174
|
-
*
|
|
175
|
-
* // With prepared statements enabled (for high-throughput local scenarios)
|
|
176
|
-
* const db2 = VibeClient({
|
|
177
|
-
* adapter: bunAdapter({ url: "postgres://...", preparedStatements: true }),
|
|
178
|
-
* });
|
|
179
|
-
* ```
|
|
180
|
-
*/
|
|
181
|
-
export function bunAdapter(options?: BunAdapterOptions): DatabaseAdapter {
|
|
182
|
-
const USE_PREPARED = options?.preparedStatements ?? false;
|
|
183
|
-
const STMT_CACHE_MAX = options?.stmtCacheMax ?? 1000;
|
|
184
|
-
const PLAN_CACHE_MODE = options?.planCacheMode ?? "force_custom_plan";
|
|
185
|
-
const stmtCache = new Map<string, TemplateStringsArray>();
|
|
186
|
-
|
|
187
|
-
let sqlInstance: SqlInstance | null = null;
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Append a PostgreSQL `options` startup parameter to a connection URL.
|
|
191
|
-
* The `options` parameter is sent during connection establishment, so it
|
|
192
|
-
* applies to every connection created by bun:sql's internal pool.
|
|
193
|
-
*/
|
|
194
|
-
function appendStartupOption(params: { url: string; option: string }): string {
|
|
195
|
-
const { url, option } = params;
|
|
196
|
-
const separator = url.includes("?") ? "&" : "?";
|
|
197
|
-
return `${url}${separator}options=${encodeURIComponent(option)}`;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Resolve the connection URL, injecting startup parameters as needed.
|
|
202
|
-
* Falls back to DATABASE_URL env var when no explicit URL is provided.
|
|
203
|
-
*
|
|
204
|
-
* Injects via the PostgreSQL `options` startup parameter (server-side runtime
|
|
205
|
-
* configuration; valid because `options` is a real Postgres startup-protocol
|
|
206
|
-
* parameter that the server parses for `-c key=value` settings):
|
|
207
|
-
* - plan_cache_mode (unless "auto")
|
|
208
|
-
* - statement_timeout (if configured)
|
|
209
|
-
*
|
|
210
|
-
* `connect_timeout` is NOT injected here — it's a libpq client-side option,
|
|
211
|
-
* not a server GUC, and bun:sql would forward it to the server as an
|
|
212
|
-
* unrecognised configuration parameter. We pass it via the bun:sql
|
|
213
|
-
* `connectionTimeout` constructor option instead (see getSql()).
|
|
214
|
-
*/
|
|
215
|
-
function resolveConnectionUrl(): string | undefined {
|
|
216
|
-
const STATEMENT_TIMEOUT = options?.statementTimeout;
|
|
217
|
-
|
|
218
|
-
// Build startup options string (for -c parameters)
|
|
219
|
-
const startupParts: string[] = [];
|
|
220
|
-
if (PLAN_CACHE_MODE !== "auto") {
|
|
221
|
-
startupParts.push(`-c plan_cache_mode=${PLAN_CACHE_MODE}`);
|
|
222
|
-
}
|
|
223
|
-
if (STATEMENT_TIMEOUT !== undefined) {
|
|
224
|
-
startupParts.push(`-c statement_timeout=${Number(STATEMENT_TIMEOUT)}`);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const needsUrlMutation = startupParts.length > 0;
|
|
228
|
-
if (!needsUrlMutation) return options?.url;
|
|
229
|
-
|
|
230
|
-
const baseUrl = options?.url ?? process.env.DATABASE_URL;
|
|
231
|
-
if (!baseUrl) return undefined;
|
|
232
|
-
|
|
233
|
-
return appendStartupOption({
|
|
234
|
-
url: baseUrl,
|
|
235
|
-
option: startupParts.join(" "),
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function getSql(): SqlInstance {
|
|
240
|
-
if (sqlInstance) return sqlInstance;
|
|
241
|
-
|
|
242
|
-
// Import Bun's SQL
|
|
243
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
244
|
-
const { SQL } = require("bun");
|
|
245
|
-
|
|
246
|
-
const sqlOptions: Record<string, unknown> = {
|
|
247
|
-
max: options?.max ?? 10,
|
|
248
|
-
// Note: We intentionally do NOT set `prepare: false` here.
|
|
249
|
-
// Setting prepare=false on the bun:sql constructor triggers a performance
|
|
250
|
-
// regression (~28ms per query on remote/SSL databases) due to a bun:sql
|
|
251
|
-
// internal behavior change. Instead, we control prepared statement usage
|
|
252
|
-
// at the execution level: sql.unsafe() for the default path (no named stmts)
|
|
253
|
-
// vs tagged templates for the preparedStatements=true path.
|
|
254
|
-
// See: https://github.com/oven-sh/bun/issues/20294
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
// Connection establishment timeout — bun:sql expects seconds.
|
|
258
|
-
// We accept milliseconds in our public API to stay consistent with pgAdapter
|
|
259
|
-
// and Node convention, then convert here.
|
|
260
|
-
if (options?.connectionTimeout !== undefined) {
|
|
261
|
-
sqlOptions.connectionTimeout = Math.max(1, Math.ceil(options.connectionTimeout / 1000));
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const connectionUrl = resolveConnectionUrl();
|
|
265
|
-
if (connectionUrl) {
|
|
266
|
-
sqlInstance = new SQL(connectionUrl, sqlOptions) as unknown as SqlInstance;
|
|
267
|
-
} else {
|
|
268
|
-
// No URL resolved — bun:sql will use PG* env vars.
|
|
269
|
-
// Startup parameters (plan_cache_mode, statement_timeout) cannot be
|
|
270
|
-
// injected via URL options in this case (would need SET on connect).
|
|
271
|
-
sqlInstance = new SQL(sqlOptions) as unknown as SqlInstance;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return sqlInstance;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* LRU-bounded synthetic tagged template cache for prepared statement reuse.
|
|
279
|
-
* bun:sql tagged templates create named prepared statements that PostgreSQL
|
|
280
|
-
* caches execution plans for. By converting dynamic SQL into synthetic tagged
|
|
281
|
-
* templates with stable references, we get the same caching behavior.
|
|
282
|
-
*
|
|
283
|
-
* Only used when `preparedStatements: true`.
|
|
284
|
-
*/
|
|
285
|
-
function getOrCreateTemplate(params: { text: string }): TemplateStringsArray {
|
|
286
|
-
const { text } = params;
|
|
287
|
-
let strings = stmtCache.get(text);
|
|
288
|
-
if (strings) {
|
|
289
|
-
// Move to end (most recently used) by re-inserting
|
|
290
|
-
stmtCache.delete(text);
|
|
291
|
-
stmtCache.set(text, strings);
|
|
292
|
-
return strings;
|
|
293
|
-
}
|
|
294
|
-
const parts = text.split(/\$\d+/);
|
|
295
|
-
strings = Object.assign(parts, { raw: parts }) as unknown as TemplateStringsArray;
|
|
296
|
-
// Evict oldest entry if at capacity
|
|
297
|
-
if (stmtCache.size >= STMT_CACHE_MAX) {
|
|
298
|
-
const oldestKey = stmtCache.keys().next().value;
|
|
299
|
-
if (oldestKey !== undefined) stmtCache.delete(oldestKey);
|
|
300
|
-
}
|
|
301
|
-
stmtCache.set(text, strings);
|
|
302
|
-
return strings;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Convert a JS array to a PostgreSQL array literal string `{val1,val2,...}`.
|
|
307
|
-
* bun:sql's extended query protocol sends values as strings, so PostgreSQL
|
|
308
|
-
* needs array parameters in its native array literal format.
|
|
309
|
-
*
|
|
310
|
-
* Element handling:
|
|
311
|
-
* - `null` / `undefined` → `NULL`
|
|
312
|
-
* - `number` / `bigint` / `boolean` → unquoted primitive
|
|
313
|
-
* - `Date` → ISO-8601 string (quoted+escaped) so PG can parse it as
|
|
314
|
-
* `timestamp[]` / `timestamptz[]`. Using the default `String(d)` would
|
|
315
|
-
* yield a non-ISO format like `Mon May 24 2026 …` that PG cannot parse.
|
|
316
|
-
* - `Buffer` / `Uint8Array` → not supported inside array literals; throw
|
|
317
|
-
* a clear error instead of silently producing `[object Object]`.
|
|
318
|
-
* - `string` → quoted with `"`/`\` escaping
|
|
319
|
-
* - other `object` → `JSON.stringify` then quoted+escaped (covers users
|
|
320
|
-
* putting plain objects into a `Json[]` / `Jsonb[]` scalar list).
|
|
321
|
-
*/
|
|
322
|
-
function toPgArrayLiteral(arr: unknown[]): string {
|
|
323
|
-
const escape = (str: string): string =>
|
|
324
|
-
`"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
325
|
-
|
|
326
|
-
const escaped = arr.map((v) => {
|
|
327
|
-
if (v === null || v === undefined) return "NULL";
|
|
328
|
-
if (typeof v === "number" || typeof v === "bigint" || typeof v === "boolean") {
|
|
329
|
-
return String(v);
|
|
330
|
-
}
|
|
331
|
-
if (v instanceof Date) {
|
|
332
|
-
return escape(v.toISOString());
|
|
333
|
-
}
|
|
334
|
-
if (Buffer.isBuffer(v) || v instanceof Uint8Array) {
|
|
335
|
-
throw new Error(
|
|
336
|
-
"toPgArrayLiteral: Buffer/Uint8Array array elements are not supported"
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
if (typeof v === "string") {
|
|
340
|
-
return escape(v);
|
|
341
|
-
}
|
|
342
|
-
if (typeof v === "object") {
|
|
343
|
-
return escape(JSON.stringify(v));
|
|
344
|
-
}
|
|
345
|
-
return escape(String(v));
|
|
346
|
-
});
|
|
347
|
-
return `{${escaped.join(",")}}`;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Normalise raw-path parameter values for bun:sql.
|
|
352
|
-
*
|
|
353
|
-
* bun:sql's extended query protocol sends params as strings, so a plain JS
|
|
354
|
-
* array binding cannot be auto-converted to a PG array literal by the driver.
|
|
355
|
-
* The ORM hot path wraps array params in `PgArray` and converts them via
|
|
356
|
-
* `formatArrayParam` before they reach the adapter — but raw queries
|
|
357
|
-
* (`$queryRawUnsafe`, `$queryRaw` template-literal, `$executeRaw*`) hand the
|
|
358
|
-
* user's array straight through.
|
|
359
|
-
*
|
|
360
|
-
* To make `db.$queryRawUnsafe('… WHERE "id" = ANY($1)', [1,2,3])` actually
|
|
361
|
-
* match rows, we convert any plain JS array element in the param list to a
|
|
362
|
-
* PG array literal here. Non-array values pass through unchanged so that
|
|
363
|
-
* bun:sql's native handling (numbers, strings, Dates, Buffers, plain objects
|
|
364
|
-
* for json columns when bun supports it) remains in effect.
|
|
365
|
-
*/
|
|
366
|
-
function normalizeRawValues(values: unknown[] | undefined): unknown[] | undefined {
|
|
367
|
-
if (!values || values.length === 0) return values;
|
|
368
|
-
let mutated: unknown[] | null = null;
|
|
369
|
-
for (let i = 0; i < values.length; i++) {
|
|
370
|
-
const v = values[i];
|
|
371
|
-
if (Array.isArray(v)) {
|
|
372
|
-
if (!mutated) mutated = values.slice();
|
|
373
|
-
mutated[i] = toPgArrayLiteral(v);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
return mutated ?? values;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Execute a query using the configured strategy.
|
|
381
|
-
* - preparedStatements=true: uses synthetic tagged templates (named prepared stmts)
|
|
382
|
-
* - preparedStatements=false: uses sql.unsafe() (replanned each time)
|
|
383
|
-
*
|
|
384
|
-
* Note: this is the ORM hot path. Scalar-list / `= ANY()` array values are
|
|
385
|
-
* already wrapped in `PgArray` by the query builder and converted to a PG
|
|
386
|
-
* array literal string via `formatArrayParam` before they reach here, so we
|
|
387
|
-
* intentionally do NOT touch raw JS arrays here (that would corrupt JSON
|
|
388
|
-
* column writes that genuinely pass a JS array as a JSON value).
|
|
389
|
-
*/
|
|
390
|
-
async function executeQuery(sql: SqlInstance, params: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
|
|
391
|
-
// Defence-in-depth: if any value is a runtime PgArray that slipped through
|
|
392
|
-
// (e.g. a caller bypassed `client.ts`'s formatArrayParam unwrap), convert
|
|
393
|
-
// it to the PG array literal string that bun:sql actually needs. The
|
|
394
|
-
// standard ORM path has already done this conversion via `formatArrayParam`,
|
|
395
|
-
// so the loop is a near-zero-cost guard for the normal case.
|
|
396
|
-
// Bug 3 — see .ai/bug-report-2026-05-24.md.
|
|
397
|
-
let values: unknown[] = params.values;
|
|
398
|
-
for (let i = 0; i < params.values.length; i++) {
|
|
399
|
-
if (params.values[i] instanceof PgArray) {
|
|
400
|
-
if (values === params.values) values = params.values.slice();
|
|
401
|
-
values[i] = toPgArrayLiteral((params.values[i] as PgArray).values);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
if (USE_PREPARED) {
|
|
405
|
-
const strings = getOrCreateTemplate({ text: params.text });
|
|
406
|
-
const result = await sql(strings, ...values);
|
|
407
|
-
return result as Record<string, unknown>[];
|
|
408
|
-
}
|
|
409
|
-
const result = await sql.unsafe(params.text, values);
|
|
410
|
-
return result as Record<string, unknown>[];
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function isolationLevelToSql(params: { level: NonNullable<TransactionOptions["isolationLevel"]> }): string {
|
|
414
|
-
switch (params.level) {
|
|
415
|
-
case "ReadCommitted": return "READ COMMITTED";
|
|
416
|
-
case "RepeatableRead": return "REPEATABLE READ";
|
|
417
|
-
case "Serializable": return "SERIALIZABLE";
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Build the typed adapter facade over any bun:sql source — the pool, a
|
|
423
|
-
* reserved connection, or an in-transaction handle. The `parentMode`
|
|
424
|
-
* discriminator tells `.transaction()` whether a nested call should issue
|
|
425
|
-
* a real BEGIN/COMMIT (or `sql.begin()`) at the pool level, or open a
|
|
426
|
-
* SAVEPOINT on the current connection.
|
|
427
|
-
*
|
|
428
|
-
* `savepointCounter` is shared by reference across every adapter created
|
|
429
|
-
* for one top-level transaction, so sibling and deeply-nested transactions
|
|
430
|
-
* always get distinct savepoint names like `vibeorm_sp_0`, `vibeorm_sp_1`,
|
|
431
|
-
* `vibeorm_sp_2`, …
|
|
432
|
-
*/
|
|
433
|
-
function createAdapter(params: {
|
|
434
|
-
sql: SqlSource;
|
|
435
|
-
parentMode: ParentMode;
|
|
436
|
-
savepointCounter: SavepointCounter;
|
|
437
|
-
}): DatabaseAdapter {
|
|
438
|
-
const { sql, parentMode, savepointCounter } = params;
|
|
439
|
-
|
|
440
|
-
async function txViaSavepoint<T>(innerFn: (txAdapter: DatabaseAdapter) => Promise<T>): Promise<T> {
|
|
441
|
-
// We're inside a transaction already — open a SAVEPOINT on this
|
|
442
|
-
// connection. Prefer the driver's native `tx.savepoint()` if exposed
|
|
443
|
-
// (modern bun:sql); else fall back to explicit `SAVEPOINT` SQL on
|
|
444
|
-
// either the in-tx handle (which lacks `.savepoint`) or a reserved
|
|
445
|
-
// connection holding a manual `BEGIN`.
|
|
446
|
-
const txSql = sql as SqlTransaction;
|
|
447
|
-
if (typeof txSql.savepoint === "function") {
|
|
448
|
-
return txSql.savepoint(async (sp) => {
|
|
449
|
-
const nestedAdapter = createAdapter({
|
|
450
|
-
sql: sp,
|
|
451
|
-
parentMode: "tx",
|
|
452
|
-
savepointCounter,
|
|
453
|
-
});
|
|
454
|
-
return innerFn(nestedAdapter);
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Fallback: explicit SAVEPOINT via unsafe(). The counter is shared by
|
|
459
|
-
// reference, so sibling/nested SAVEPOINTs never collide.
|
|
460
|
-
const spName = `vibeorm_sp_${savepointCounter.n++}`;
|
|
461
|
-
await txSql.unsafe(`SAVEPOINT ${spName}`);
|
|
462
|
-
try {
|
|
463
|
-
const nestedAdapter = createAdapter({
|
|
464
|
-
sql: txSql,
|
|
465
|
-
parentMode: "tx",
|
|
466
|
-
savepointCounter,
|
|
467
|
-
});
|
|
468
|
-
const result = await innerFn(nestedAdapter);
|
|
469
|
-
await txSql.unsafe(`RELEASE SAVEPOINT ${spName}`);
|
|
470
|
-
return result;
|
|
471
|
-
} catch (err) {
|
|
472
|
-
// Best-effort rollback — mirror pg adapter behaviour so we never
|
|
473
|
-
// mask the original error if the ROLLBACK itself fails.
|
|
474
|
-
try { await txSql.unsafe(`ROLLBACK TO SAVEPOINT ${spName}`); } catch { /* rollback best-effort */ }
|
|
475
|
-
throw err;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
async function txViaPool<T>(
|
|
480
|
-
innerFn: (txAdapter: DatabaseAdapter) => Promise<T>,
|
|
481
|
-
options?: TransactionOptions
|
|
482
|
-
): Promise<T> {
|
|
483
|
-
const pool = sql as SqlInstance;
|
|
484
|
-
|
|
485
|
-
// Fast path: no custom options — use native sql.begin() for best perf.
|
|
486
|
-
if (!options?.isolationLevel && !options?.timeout) {
|
|
487
|
-
return pool.begin(async (txSql: SqlTransaction) => {
|
|
488
|
-
const txAdapter = createAdapter({
|
|
489
|
-
sql: txSql,
|
|
490
|
-
parentMode: "tx",
|
|
491
|
-
savepointCounter,
|
|
492
|
-
});
|
|
493
|
-
return innerFn(txAdapter);
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Manual path: reserve a single connection for custom BEGIN options.
|
|
498
|
-
const reserved = await pool.reserve();
|
|
499
|
-
try {
|
|
500
|
-
const isolation = options.isolationLevel
|
|
501
|
-
? ` ISOLATION LEVEL ${isolationLevelToSql({ level: options.isolationLevel })}`
|
|
502
|
-
: "";
|
|
503
|
-
await reserved.unsafe(`BEGIN${isolation}`);
|
|
504
|
-
|
|
505
|
-
if (options.timeout) {
|
|
506
|
-
await reserved.unsafe(`SET LOCAL statement_timeout = ${Number(options.timeout)}`);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
const txAdapter = createAdapter({
|
|
510
|
-
sql: reserved,
|
|
511
|
-
parentMode: "tx",
|
|
512
|
-
savepointCounter,
|
|
513
|
-
});
|
|
514
|
-
const result = await innerFn(txAdapter);
|
|
515
|
-
await reserved.unsafe("COMMIT");
|
|
516
|
-
return result;
|
|
517
|
-
} catch (err) {
|
|
518
|
-
try { await reserved.unsafe("ROLLBACK"); } catch { /* rollback best-effort */ }
|
|
519
|
-
throw err;
|
|
520
|
-
} finally {
|
|
521
|
-
reserved.release();
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
return {
|
|
526
|
-
async execute(execParams) {
|
|
527
|
-
return executeQuery(sql as SqlInstance, execParams);
|
|
528
|
-
},
|
|
529
|
-
|
|
530
|
-
async executeUnsafe(execParams) {
|
|
531
|
-
const values = normalizeRawValues(execParams.values);
|
|
532
|
-
const result = await sql.unsafe(execParams.text, values);
|
|
533
|
-
const resultAny = result as unknown as Record<string, unknown>;
|
|
534
|
-
let affectedRows: number;
|
|
535
|
-
if (typeof resultAny.count === "number") {
|
|
536
|
-
affectedRows = resultAny.count;
|
|
537
|
-
} else if (typeof resultAny.affectedRows === "number") {
|
|
538
|
-
affectedRows = resultAny.affectedRows;
|
|
539
|
-
} else {
|
|
540
|
-
affectedRows = (result as unknown[]).length;
|
|
541
|
-
}
|
|
542
|
-
return {
|
|
543
|
-
rows: result as Record<string, unknown>[],
|
|
544
|
-
affectedRows,
|
|
545
|
-
};
|
|
546
|
-
},
|
|
547
|
-
|
|
548
|
-
async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>, options?: TransactionOptions): Promise<T> {
|
|
549
|
-
return parentMode === "pool"
|
|
550
|
-
? txViaPool(fn, options)
|
|
551
|
-
: txViaSavepoint(fn);
|
|
552
|
-
},
|
|
553
|
-
|
|
554
|
-
async connect() {
|
|
555
|
-
// Only the pool-level adapter can reserve a fresh connection.
|
|
556
|
-
// Adapters scoped to a reserved/tx connection are already connected.
|
|
557
|
-
if (parentMode === "pool") {
|
|
558
|
-
const reserved = await (sql as SqlInstance).reserve();
|
|
559
|
-
reserved.release();
|
|
560
|
-
}
|
|
561
|
-
},
|
|
562
|
-
|
|
563
|
-
async disconnect() {
|
|
564
|
-
if (parentMode === "pool") {
|
|
565
|
-
await (sql as SqlInstance).close();
|
|
566
|
-
sqlInstance = null;
|
|
567
|
-
}
|
|
568
|
-
// Inside a transaction `.disconnect()` is a no-op — the pool owns
|
|
569
|
-
// the connection lifecycle.
|
|
570
|
-
},
|
|
571
|
-
|
|
572
|
-
formatArrayParam(values: unknown[]): unknown {
|
|
573
|
-
return toPgArrayLiteral(values);
|
|
574
|
-
},
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// Create a lazy adapter that initializes the SQL connection on first use.
|
|
579
|
-
// Each top-level call lazily resolves the underlying `SqlInstance`. The
|
|
580
|
-
// `savepointCounter` for every top-level transaction is freshly created
|
|
581
|
-
// inside that transaction's own `txViaPool` / `txViaSavepoint` — at the
|
|
582
|
-
// pool level there's no shared counter to manage.
|
|
583
|
-
function poolAdapter(): DatabaseAdapter {
|
|
584
|
-
return createAdapter({
|
|
585
|
-
sql: getSql(),
|
|
586
|
-
parentMode: "pool",
|
|
587
|
-
savepointCounter: { n: 0 },
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const adapter: DatabaseAdapter = {
|
|
592
|
-
async execute(params) {
|
|
593
|
-
return poolAdapter().execute(params);
|
|
594
|
-
},
|
|
595
|
-
|
|
596
|
-
async executeUnsafe(params) {
|
|
597
|
-
return poolAdapter().executeUnsafe(params);
|
|
598
|
-
},
|
|
599
|
-
|
|
600
|
-
async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>, options?: TransactionOptions): Promise<T> {
|
|
601
|
-
return poolAdapter().transaction(fn, options);
|
|
602
|
-
},
|
|
603
|
-
|
|
604
|
-
async connect() {
|
|
605
|
-
return poolAdapter().connect();
|
|
606
|
-
},
|
|
607
|
-
|
|
608
|
-
async disconnect() {
|
|
609
|
-
if (sqlInstance) {
|
|
610
|
-
await sqlInstance.close();
|
|
611
|
-
sqlInstance = null;
|
|
612
|
-
}
|
|
613
|
-
},
|
|
614
|
-
|
|
615
|
-
formatArrayParam(values: unknown[]): unknown {
|
|
616
|
-
return toPgArrayLiteral(values);
|
|
617
|
-
},
|
|
618
|
-
};
|
|
619
|
-
|
|
620
|
-
return adapter;
|
|
621
|
-
}
|