@vibeorm/adapter-bun 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 +48 -0
  2. package/package.json +41 -0
  3. package/src/index.ts +308 -0
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @vibeorm/adapter-bun
2
+
3
+ Bun-native database adapter for VibeORM using Bun's built-in `bun:sql` driver.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @vibeorm/adapter-bun
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ The generated client uses this adapter by default. You can also create one manually:
14
+
15
+ ```ts
16
+ import { bunAdapter } from "@vibeorm/adapter-bun";
17
+
18
+ const adapter = bunAdapter({
19
+ url: "postgres://user:pass@localhost:5432/mydb",
20
+ max: 20,
21
+ });
22
+ ```
23
+
24
+ Then pass it to the client:
25
+
26
+ ```ts
27
+ import { VibeClient } from "./generated/vibeorm/index.ts";
28
+
29
+ const db = VibeClient({
30
+ adapter: bunAdapter({ url: "postgres://..." }),
31
+ });
32
+ ```
33
+
34
+ ## Options
35
+
36
+ ```ts
37
+ type BunAdapterOptions = {
38
+ url?: string; // Connection string (defaults to DATABASE_URL env)
39
+ max?: number; // Pool size (default: 20)
40
+ preparedStatements?: boolean; // Enable prepared statement caching (default: false)
41
+ stmtCacheMax?: number; // Max cached prepared statements (default: 1000)
42
+ planCacheMode?: string; // PostgreSQL plan_cache_mode (default: "force_custom_plan")
43
+ };
44
+ ```
45
+
46
+ ## License
47
+
48
+ [MIT](../../LICENSE)
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@vibeorm/adapter-bun",
3
+ "version": "1.0.0",
4
+ "description": "Bun-native database adapter for VibeORM using bun:sql",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "orm",
8
+ "adapter",
9
+ "bun",
10
+ "postgresql",
11
+ "typescript"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "default": "./src/index.ts",
17
+ "types": "./src/index.ts"
18
+ }
19
+ },
20
+ "files": [
21
+ "src"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/vibeorm/vibeorm.git",
26
+ "directory": "packages/adapter-bun"
27
+ },
28
+ "homepage": "https://github.com/vibeorm/vibeorm/tree/master/packages/adapter-bun",
29
+ "bugs": {
30
+ "url": "https://github.com/vibeorm/vibeorm/issues"
31
+ },
32
+ "engines": {
33
+ "bun": ">=1.1.0"
34
+ },
35
+ "dependencies": {
36
+ "@vibeorm/runtime": "workspace:*"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,308 @@
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 } from "@vibeorm/runtime";
10
+
11
+ export type BunAdapterOptions = {
12
+ /** PostgreSQL connection URL. Falls back to DATABASE_URL env var if not provided. */
13
+ url?: string;
14
+ /** Maximum number of connections in the pool (default: 10). */
15
+ max?: number;
16
+ /**
17
+ * Whether to use named prepared statements for read queries (default: false).
18
+ *
19
+ * When `true`, the adapter caches SQL texts as synthetic tagged templates,
20
+ * creating named prepared statements that PostgreSQL caches execution plans for.
21
+ * This saves ~0.1ms of planning time per query but can cause PostgreSQL to
22
+ * switch to a "generic plan" after 5 executions, which may choose a worse
23
+ * index for queries with range filters or skewed data distributions.
24
+ *
25
+ * When `false` (default), the adapter uses `sql.unsafe()` which bypasses
26
+ * bun:sql's tagged template / named statement path. PostgreSQL replans each
27
+ * execution using the actual parameter values, always choosing the optimal index.
28
+ *
29
+ * Note: The bun:sql `prepare` constructor option is always left at its default
30
+ * (`true`). Setting `prepare: false` on the constructor triggers a bun:sql
31
+ * performance regression (~28ms per query on remote databases). Instead, we
32
+ * only control the execution path: `sql.unsafe()` vs tagged templates.
33
+ *
34
+ * Recommendation: Leave as `false` unless profiling shows planning overhead
35
+ * is a bottleneck (rare — typically only for sub-millisecond queries at
36
+ * very high throughput).
37
+ */
38
+ preparedStatements?: boolean;
39
+ /**
40
+ * Maximum number of entries in the prepared statement cache (default: 1000).
41
+ * Only used when `preparedStatements: true`.
42
+ * Prevents unbounded memory growth in long-running processes.
43
+ * Each unique SQL text consumes one slot. LRU eviction when full.
44
+ */
45
+ stmtCacheMax?: number;
46
+ /**
47
+ * Controls PostgreSQL's plan caching behavior for prepared statements
48
+ * (default: `"force_custom_plan"`).
49
+ *
50
+ * bun:sql uses the extended query protocol, which creates implicit prepared
51
+ * statements internally. After ~5 executions of the same query text,
52
+ * PostgreSQL may switch to a "generic plan" that ignores actual parameter
53
+ * values. This causes significant performance regressions for queries with
54
+ * range filters (`>=`, `<=`, `BETWEEN`), pattern matching (`LIKE`), or
55
+ * skewed data distributions — the planner picks sequential scans instead
56
+ * of index scans because it can't estimate selectivity without real values.
57
+ *
58
+ * - `"force_custom_plan"` (default): Always generates plans using actual
59
+ * parameter values. Costs ~0.1 ms extra planning per query but always
60
+ * picks the optimal index. Recommended for most workloads.
61
+ * - `"auto"`: PostgreSQL's default — switches to generic plans after ~5
62
+ * executions if the estimated cost is similar. Can cause 2-4× regressions
63
+ * on filtered COUNT / aggregate queries.
64
+ * - `"force_generic_plan"`: Always uses generic plans (not recommended).
65
+ *
66
+ * Injected via the PostgreSQL `options` startup parameter so it applies to
67
+ * every connection in the pool automatically. Requires PostgreSQL 12+.
68
+ */
69
+ planCacheMode?: "auto" | "force_custom_plan" | "force_generic_plan";
70
+ };
71
+
72
+ // ─── Internal bun:sql types ───────────────────────────────────────
73
+
74
+ type SqlReserved = {
75
+ (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
76
+ release(): void;
77
+ };
78
+
79
+ type SqlInstance = {
80
+ (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>;
81
+ unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
82
+ begin<T>(fn: (tx: SqlInstance) => Promise<T>): Promise<T>;
83
+ reserve(): Promise<SqlReserved>;
84
+ close(): Promise<void>;
85
+ };
86
+
87
+ /**
88
+ * Create a VibeORM database adapter using Bun's built-in SQL driver.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * import { bunAdapter } from "@vibeorm/adapter-bun";
93
+ * import { VibeClient } from "./generated/vibeorm";
94
+ *
95
+ * const db = VibeClient({
96
+ * adapter: bunAdapter({ url: "postgres://...", max: 10 }),
97
+ * });
98
+ *
99
+ * // With prepared statements enabled (for high-throughput local scenarios)
100
+ * const db2 = VibeClient({
101
+ * adapter: bunAdapter({ url: "postgres://...", preparedStatements: true }),
102
+ * });
103
+ * ```
104
+ */
105
+ export function bunAdapter(options?: BunAdapterOptions): DatabaseAdapter {
106
+ const USE_PREPARED = options?.preparedStatements ?? false;
107
+ const STMT_CACHE_MAX = options?.stmtCacheMax ?? 1000;
108
+ const PLAN_CACHE_MODE = options?.planCacheMode ?? "force_custom_plan";
109
+ const stmtCache = new Map<string, TemplateStringsArray>();
110
+
111
+ let sqlInstance: SqlInstance | null = null;
112
+
113
+ /**
114
+ * Append a PostgreSQL `options` startup parameter to a connection URL.
115
+ * The `options` parameter is sent during connection establishment, so it
116
+ * applies to every connection created by bun:sql's internal pool.
117
+ */
118
+ function appendStartupOption(params: { url: string; option: string }): string {
119
+ const { url, option } = params;
120
+ const separator = url.includes("?") ? "&" : "?";
121
+ return `${url}${separator}options=${encodeURIComponent(option)}`;
122
+ }
123
+
124
+ /**
125
+ * Resolve the connection URL, injecting plan_cache_mode if needed.
126
+ * Falls back to DATABASE_URL env var when no explicit URL is provided.
127
+ */
128
+ function resolveConnectionUrl(): string | undefined {
129
+ if (PLAN_CACHE_MODE === "auto") return options?.url;
130
+
131
+ const baseUrl = options?.url ?? process.env.DATABASE_URL;
132
+ if (!baseUrl) return undefined;
133
+
134
+ return appendStartupOption({
135
+ url: baseUrl,
136
+ option: `-c plan_cache_mode=${PLAN_CACHE_MODE}`,
137
+ });
138
+ }
139
+
140
+ function getSql(): SqlInstance {
141
+ if (sqlInstance) return sqlInstance;
142
+
143
+ // Import Bun's SQL
144
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
145
+ const { SQL } = require("bun");
146
+
147
+ const sqlOptions: Record<string, unknown> = {
148
+ max: options?.max ?? 10,
149
+ // Note: We intentionally do NOT set `prepare: false` here.
150
+ // Setting prepare=false on the bun:sql constructor triggers a performance
151
+ // regression (~28ms per query on remote/SSL databases) due to a bun:sql
152
+ // internal behavior change. Instead, we control prepared statement usage
153
+ // at the execution level: sql.unsafe() for the default path (no named stmts)
154
+ // vs tagged templates for the preparedStatements=true path.
155
+ // See: https://github.com/oven-sh/bun/issues/20294
156
+ };
157
+
158
+ const connectionUrl = resolveConnectionUrl();
159
+ if (connectionUrl) {
160
+ sqlInstance = new SQL(connectionUrl, sqlOptions) as unknown as SqlInstance;
161
+ } else if (options?.url) {
162
+ // URL provided but planCacheMode is "auto" — use URL as-is
163
+ sqlInstance = new SQL(options.url, sqlOptions) as unknown as SqlInstance;
164
+ } else {
165
+ // No URL — bun:sql will use PG* env vars. plan_cache_mode cannot be
166
+ // injected via startup options in this case (would need SET on connect).
167
+ sqlInstance = new SQL(sqlOptions) as unknown as SqlInstance;
168
+ }
169
+
170
+ return sqlInstance;
171
+ }
172
+
173
+ /**
174
+ * LRU-bounded synthetic tagged template cache for prepared statement reuse.
175
+ * bun:sql tagged templates create named prepared statements that PostgreSQL
176
+ * caches execution plans for. By converting dynamic SQL into synthetic tagged
177
+ * templates with stable references, we get the same caching behavior.
178
+ *
179
+ * Only used when `preparedStatements: true`.
180
+ */
181
+ function getOrCreateTemplate(params: { text: string }): TemplateStringsArray {
182
+ const { text } = params;
183
+ let strings = stmtCache.get(text);
184
+ if (strings) {
185
+ // Move to end (most recently used) by re-inserting
186
+ stmtCache.delete(text);
187
+ stmtCache.set(text, strings);
188
+ return strings;
189
+ }
190
+ const parts = text.split(/\$\d+/);
191
+ strings = Object.assign(parts, { raw: parts }) as unknown as TemplateStringsArray;
192
+ // Evict oldest entry if at capacity
193
+ if (stmtCache.size >= STMT_CACHE_MAX) {
194
+ const oldestKey = stmtCache.keys().next().value;
195
+ if (oldestKey !== undefined) stmtCache.delete(oldestKey);
196
+ }
197
+ stmtCache.set(text, strings);
198
+ return strings;
199
+ }
200
+
201
+ /**
202
+ * Convert a JS array to a PostgreSQL array literal string `{val1,val2,...}`.
203
+ * bun:sql's extended query protocol sends values as strings, so PostgreSQL
204
+ * needs array parameters in its native array literal format.
205
+ */
206
+ function toPgArrayLiteral(arr: unknown[]): string {
207
+ const escaped = arr.map((v) => {
208
+ if (v === null || v === undefined) return "NULL";
209
+ if (typeof v === "number" || typeof v === "bigint" || typeof v === "boolean") return String(v);
210
+ const str = String(v);
211
+ return `"${str.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
212
+ });
213
+ return `{${escaped.join(",")}}`;
214
+ }
215
+
216
+ /**
217
+ * Execute a query using the configured strategy.
218
+ * - preparedStatements=true: uses synthetic tagged templates (named prepared stmts)
219
+ * - preparedStatements=false: uses sql.unsafe() (replanned each time)
220
+ */
221
+ async function executeQuery(sql: SqlInstance, params: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
222
+ if (USE_PREPARED) {
223
+ const strings = getOrCreateTemplate({ text: params.text });
224
+ const result = await sql(strings, ...params.values);
225
+ return result as Record<string, unknown>[];
226
+ }
227
+ const result = await sql.unsafe(params.text, params.values);
228
+ return result as Record<string, unknown>[];
229
+ }
230
+
231
+ function createAdapter(sql: SqlInstance): DatabaseAdapter {
232
+ return {
233
+ async execute(params) {
234
+ return executeQuery(sql, params);
235
+ },
236
+
237
+ async executeUnsafe(params) {
238
+ const result = await sql.unsafe(params.text, params.values);
239
+ const resultAny = result as unknown as Record<string, unknown>;
240
+ let affectedRows: number;
241
+ if (typeof resultAny.count === "number") {
242
+ affectedRows = resultAny.count;
243
+ } else if (typeof resultAny.affectedRows === "number") {
244
+ affectedRows = resultAny.affectedRows;
245
+ } else {
246
+ affectedRows = (result as unknown[]).length;
247
+ }
248
+ return {
249
+ rows: result as Record<string, unknown>[],
250
+ affectedRows,
251
+ };
252
+ },
253
+
254
+ async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>): Promise<T> {
255
+ return sql.begin(async (txSql: SqlInstance) => {
256
+ const txAdapter = createAdapter(txSql);
257
+ return fn(txAdapter);
258
+ });
259
+ },
260
+
261
+ async connect() {
262
+ const reserved = await sql.reserve();
263
+ reserved.release();
264
+ },
265
+
266
+ async disconnect() {
267
+ await sql.close();
268
+ sqlInstance = null;
269
+ },
270
+
271
+ formatArrayParam(values: unknown[]): unknown {
272
+ return toPgArrayLiteral(values);
273
+ },
274
+ };
275
+ }
276
+
277
+ // Create a lazy adapter that initializes the SQL connection on first use
278
+ const adapter: DatabaseAdapter = {
279
+ async execute(params) {
280
+ return createAdapter(getSql()).execute(params);
281
+ },
282
+
283
+ async executeUnsafe(params) {
284
+ return createAdapter(getSql()).executeUnsafe(params);
285
+ },
286
+
287
+ async transaction<T>(fn: (txAdapter: DatabaseAdapter) => Promise<T>): Promise<T> {
288
+ return createAdapter(getSql()).transaction(fn);
289
+ },
290
+
291
+ async connect() {
292
+ return createAdapter(getSql()).connect();
293
+ },
294
+
295
+ async disconnect() {
296
+ if (sqlInstance) {
297
+ await sqlInstance.close();
298
+ sqlInstance = null;
299
+ }
300
+ },
301
+
302
+ formatArrayParam(values: unknown[]): unknown {
303
+ return toPgArrayLiteral(values);
304
+ },
305
+ };
306
+
307
+ return adapter;
308
+ }