@mandujs/core 0.41.2 → 0.43.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/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
package/src/db/index.ts
CHANGED
|
@@ -1,513 +1,513 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @mandujs/core/db
|
|
3
|
-
*
|
|
4
|
-
* Thin, production-grade wrapper around **native `Bun.SQL`** (Bun 1.3.x).
|
|
5
|
-
* Provides a callable tagged-template handle with `.one()`, `.transaction()`,
|
|
6
|
-
* and `.close()` affordances while preserving Bun.sql's parameter-safe
|
|
7
|
-
* placeholder semantics end-to-end.
|
|
8
|
-
*
|
|
9
|
-
* ## Providers
|
|
10
|
-
*
|
|
11
|
-
* - `postgres://…` / `postgresql://…` → Postgres
|
|
12
|
-
* - `mysql://…` → MySQL
|
|
13
|
-
* - `sqlite::memory:` / `sqlite:./file.db` / `sqlite://./file.db` → SQLite
|
|
14
|
-
*
|
|
15
|
-
* The `provider` config key overrides URL-based detection when the scheme
|
|
16
|
-
* is ambiguous. No other DB engines are supported — Bun.SQL itself accepts
|
|
17
|
-
* `postgres`, `sqlite`, `mysql`, and `mariadb` (aliased to mysql here).
|
|
18
|
-
*
|
|
19
|
-
* ## Why a wrapper
|
|
20
|
-
*
|
|
21
|
-
* Bun.SQL is already excellent. What this module adds:
|
|
22
|
-
*
|
|
23
|
-
* 1. **Provider detection** — consistent scheme parsing; consumers read
|
|
24
|
-
* `db.provider` instead of sniffing `Bun.SQL.options` themselves.
|
|
25
|
-
* 2. **`.one()` helper** — zero-or-one-row query with clear errors for
|
|
26
|
-
* unexpected multi-row results. (Bun.SQL's raw `[0]` access silently
|
|
27
|
-
* drops extra rows.)
|
|
28
|
-
* 3. **Typed transactions** — `tx` inside `transaction(fn)` is the same
|
|
29
|
-
* `Db` shape as the outer handle (Bun.SQL's `begin()` returns a
|
|
30
|
-
* callable without `.one` / `.transaction` / `.close`).
|
|
31
|
-
* 4. **Lazy runtime probe** — `createDb()` never throws when Bun.SQL is
|
|
32
|
-
* unavailable; the first query does, with a version-specific message.
|
|
33
|
-
*
|
|
34
|
-
* ## Parameter binding
|
|
35
|
-
*
|
|
36
|
-
* Placeholders are passed through Bun.SQL's native parameter binding —
|
|
37
|
-
* never string-concatenated into the SQL text. Example:
|
|
38
|
-
*
|
|
39
|
-
* ```ts
|
|
40
|
-
* const user = await db.one<User>`SELECT * FROM users WHERE name = ${name}`;
|
|
41
|
-
* ```
|
|
42
|
-
*
|
|
43
|
-
* The `${name}` is routed as a bound parameter even if it contains `'`,
|
|
44
|
-
* `--`, or other SQL metacharacters. There is no safe "unsafe interpolate"
|
|
45
|
-
* escape hatch on the public surface — use Bun.SQL directly if you need
|
|
46
|
-
* `sql.unsafe()` semantics.
|
|
47
|
-
*
|
|
48
|
-
* @example
|
|
49
|
-
* ```ts
|
|
50
|
-
* import { createDb } from "@mandujs/core/db";
|
|
51
|
-
*
|
|
52
|
-
* const db = createDb({ url: "postgres://user:pass@localhost/app" });
|
|
53
|
-
*
|
|
54
|
-
* await db`CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)`;
|
|
55
|
-
* await db`INSERT INTO users (name) VALUES (${"alice"})`;
|
|
56
|
-
*
|
|
57
|
-
* const one = await db.one<{ id: number; name: string }>`
|
|
58
|
-
* SELECT id, name FROM users WHERE name = ${"alice"}
|
|
59
|
-
* `;
|
|
60
|
-
*
|
|
61
|
-
* await db.transaction(async (tx) => {
|
|
62
|
-
* await tx`INSERT INTO users (name) VALUES (${"bob"})`;
|
|
63
|
-
* await tx`INSERT INTO users (name) VALUES (${"carol"})`;
|
|
64
|
-
* });
|
|
65
|
-
*
|
|
66
|
-
* await db.close();
|
|
67
|
-
* ```
|
|
68
|
-
*
|
|
69
|
-
* @module db
|
|
70
|
-
*/
|
|
71
|
-
|
|
72
|
-
// ─── Public types ───────────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
/** A single query result row. */
|
|
75
|
-
export type Row = Record<string, unknown>;
|
|
76
|
-
|
|
77
|
-
/** The three providers this wrapper supports. */
|
|
78
|
-
export type SqlProvider = "postgres" | "mysql" | "sqlite";
|
|
79
|
-
|
|
80
|
-
/** Configuration for {@link createDb}. */
|
|
81
|
-
export interface DbConfig {
|
|
82
|
-
/**
|
|
83
|
-
* Connection URL.
|
|
84
|
-
*
|
|
85
|
-
* - `sqlite::memory:` — in-memory SQLite
|
|
86
|
-
* - `sqlite:./data.db` / `sqlite://./data.db` — file-backed SQLite
|
|
87
|
-
* - `postgres://user:pass@host:5432/db` (or `postgresql://…`)
|
|
88
|
-
* - `mysql://user:pass@host:3306/db`
|
|
89
|
-
*/
|
|
90
|
-
url: string;
|
|
91
|
-
/**
|
|
92
|
-
* Override provider detection. Useful when embedding a non-standard URL
|
|
93
|
-
* scheme (e.g., a secrets manager placeholder the user rewrites at boot).
|
|
94
|
-
* When set, wins over URL-scheme sniffing.
|
|
95
|
-
*/
|
|
96
|
-
provider?: SqlProvider;
|
|
97
|
-
/**
|
|
98
|
-
* Max concurrent connections. Default: `10` for Postgres/MySQL, `1` for
|
|
99
|
-
* SQLite (SQLite is serialised by the engine; more than one connection
|
|
100
|
-
* just queues inside Bun).
|
|
101
|
-
*/
|
|
102
|
-
max?: number;
|
|
103
|
-
/**
|
|
104
|
-
* Additional options forwarded to the `Bun.SQL` constructor. Escape hatch
|
|
105
|
-
* for advanced configuration — `ssl`, `idleTimeout`, `tls`, `bigint`, etc.
|
|
106
|
-
*
|
|
107
|
-
* The keys `url`, `adapter`, and `max` from this object are ignored to
|
|
108
|
-
* keep public surface authoritative.
|
|
109
|
-
*/
|
|
110
|
-
options?: Record<string, unknown>;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* A database handle. Invoking it as a **tagged template** runs a query and
|
|
115
|
-
* returns the full result array. The attached methods support one-shot
|
|
116
|
-
* reads, transactions, and shutdown.
|
|
117
|
-
*/
|
|
118
|
-
export interface Db {
|
|
119
|
-
/**
|
|
120
|
-
* Tagged-template query. Values are bound as parameters, never
|
|
121
|
-
* interpolated as SQL text.
|
|
122
|
-
*/
|
|
123
|
-
<T extends Row = Row>(
|
|
124
|
-
strings: TemplateStringsArray,
|
|
125
|
-
...values: unknown[]
|
|
126
|
-
): Promise<T[]>;
|
|
127
|
-
|
|
128
|
-
/** Detected provider (`"postgres"`, `"mysql"`, or `"sqlite"`). */
|
|
129
|
-
readonly provider: SqlProvider;
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Runs the query and returns at most one row.
|
|
133
|
-
*
|
|
134
|
-
* - `0` rows → resolves to `null`
|
|
135
|
-
* - `1` row → resolves to that row
|
|
136
|
-
* - `>= 2` rows → rejects with an Error naming the actual count
|
|
137
|
-
*
|
|
138
|
-
* Use when the query is expected to match at most one record (lookup by
|
|
139
|
-
* unique key, `LIMIT 1`, etc.).
|
|
140
|
-
*/
|
|
141
|
-
one<T extends Row = Row>(
|
|
142
|
-
strings: TemplateStringsArray,
|
|
143
|
-
...values: unknown[]
|
|
144
|
-
): Promise<T | null>;
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Runs `fn` inside a transaction. Commits if `fn` resolves; rolls back
|
|
148
|
-
* if it throws (and re-throws the original error).
|
|
149
|
-
*
|
|
150
|
-
* Nested transactions are NOT supported — call `transaction` from within
|
|
151
|
-
* another `transaction` and you'll get an error surfaced by Bun.SQL
|
|
152
|
-
* ("cannot call begin inside a transaction use savepoint() instead").
|
|
153
|
-
* Use savepoints via raw Bun.SQL if you need nesting.
|
|
154
|
-
*/
|
|
155
|
-
transaction<R>(fn: (tx: Db) => Promise<R>): Promise<R>;
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Closes the connection pool. Subsequent queries reject with a clear
|
|
159
|
-
* "pool closed" error. Calling `close()` twice is a no-op (idempotent).
|
|
160
|
-
*/
|
|
161
|
-
close(): Promise<void>;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// ─── Bun runtime surface (structural; no `any`) ─────────────────────────────
|
|
165
|
-
|
|
166
|
-
/** Shape of the options object Bun.SQL accepts. */
|
|
167
|
-
interface BunSqlOptions {
|
|
168
|
-
url?: string;
|
|
169
|
-
adapter?: string;
|
|
170
|
-
max?: number;
|
|
171
|
-
filename?: string;
|
|
172
|
-
hostname?: string;
|
|
173
|
-
port?: number;
|
|
174
|
-
username?: string;
|
|
175
|
-
password?: string;
|
|
176
|
-
database?: string;
|
|
177
|
-
[key: string]: unknown;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* A Bun.SQL instance — itself a callable tagged-template function with
|
|
182
|
-
* methods attached. We only model the subset we actually use.
|
|
183
|
-
*/
|
|
184
|
-
interface BunSqlInstance {
|
|
185
|
-
<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<
|
|
186
|
-
T[] & { count?: number; command?: string }
|
|
187
|
-
>;
|
|
188
|
-
begin<R>(fn: (tx: BunSqlInstance) => Promise<R>): Promise<R>;
|
|
189
|
-
close(): Promise<void>;
|
|
190
|
-
readonly options?: BunSqlOptions;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Constructor surface — `Bun.SQL` is a class; we only need the `new`
|
|
195
|
-
* signature. Accepts either a URL string or a full options object.
|
|
196
|
-
*/
|
|
197
|
-
export type BunSqlCtor = new (
|
|
198
|
-
urlOrOptions: string | BunSqlOptions,
|
|
199
|
-
) => BunSqlInstance;
|
|
200
|
-
|
|
201
|
-
// ─── Provider detection ─────────────────────────────────────────────────────
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Derives a {@link SqlProvider} from a connection URL. Throws a clear
|
|
205
|
-
* error for unsupported / ambiguous schemes so the call site fails early
|
|
206
|
-
* instead of passing a half-formed config to Bun.SQL.
|
|
207
|
-
*/
|
|
208
|
-
export function detectProvider(url: string): SqlProvider {
|
|
209
|
-
// Matches `sqlite::memory:`, `sqlite://path`, `sqlite:./file` — any
|
|
210
|
-
// "starts with sqlite:" variant. We intentionally accept the no-slash
|
|
211
|
-
// form because it's what Bun.SQL documents as canonical.
|
|
212
|
-
if (url.startsWith("sqlite:")) return "sqlite";
|
|
213
|
-
if (url.startsWith("postgres://") || url.startsWith("postgresql://")) {
|
|
214
|
-
return "postgres";
|
|
215
|
-
}
|
|
216
|
-
if (url.startsWith("mysql://") || url.startsWith("mariadb://")) {
|
|
217
|
-
return "mysql";
|
|
218
|
-
}
|
|
219
|
-
throw new Error(
|
|
220
|
-
`[@mandujs/core/db] Unable to detect provider from url: ${JSON.stringify(
|
|
221
|
-
url,
|
|
222
|
-
)}. Supported schemes: postgres://, postgresql://, mysql://, mariadb://, sqlite:. ` +
|
|
223
|
-
`Pass { provider: "postgres" | "mysql" | "sqlite" } to override.`,
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Maps our {@link SqlProvider} to the adapter name Bun.SQL expects. */
|
|
228
|
-
function providerToAdapter(p: SqlProvider): string {
|
|
229
|
-
// Bun.SQL uses "postgres" / "sqlite" / "mysql" verbatim; no mapping fuzz.
|
|
230
|
-
return p;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** Default `max` connections per provider. SQLite serialises at the engine. */
|
|
234
|
-
function defaultMax(p: SqlProvider): number {
|
|
235
|
-
return p === "sqlite" ? 1 : 10;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// ─── Bun runtime probe ──────────────────────────────────────────────────────
|
|
239
|
-
|
|
240
|
-
function getBunSqlCtor(): BunSqlCtor {
|
|
241
|
-
const g = globalThis as unknown as { Bun?: { SQL?: BunSqlCtor } };
|
|
242
|
-
if (!g.Bun || typeof g.Bun.SQL !== "function") {
|
|
243
|
-
throw new Error(
|
|
244
|
-
"[@mandujs/core/db] Bun.sql is unavailable — this module requires Bun runtime >= 1.3.x. " +
|
|
245
|
-
"Install/upgrade Bun: https://bun.com/docs/installation",
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
return g.Bun.SQL;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ─── Error helpers ──────────────────────────────────────────────────────────
|
|
252
|
-
|
|
253
|
-
const POOL_CLOSED_MESSAGE =
|
|
254
|
-
"[@mandujs/core/db] pool closed — query issued after Db.close().";
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Structural check for "connection/pool closed" errors that Bun.SQL raises
|
|
258
|
-
* after `.close()`. Bun surfaces these as `SQLiteError` / `PostgresError` /
|
|
259
|
-
* `MySQLError` with a `code` ending in `CLOSED`.
|
|
260
|
-
*/
|
|
261
|
-
function isPoolClosedError(err: unknown): boolean {
|
|
262
|
-
if (!err || typeof err !== "object") return false;
|
|
263
|
-
const e = err as { code?: unknown; message?: unknown };
|
|
264
|
-
if (typeof e.code === "string" && e.code.includes("CLOSED")) return true;
|
|
265
|
-
if (
|
|
266
|
-
typeof e.message === "string" &&
|
|
267
|
-
/(connection|pool)\s+closed/i.test(e.message)
|
|
268
|
-
) {
|
|
269
|
-
return true;
|
|
270
|
-
}
|
|
271
|
-
return false;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Internal factory accepting an injectable `Bun.SQL` constructor — used by
|
|
278
|
-
* unit tests to swap in a fake implementation. Production callers use
|
|
279
|
-
* {@link createDb}, which binds this to `Bun.SQL`.
|
|
280
|
-
*
|
|
281
|
-
* @internal
|
|
282
|
-
*/
|
|
283
|
-
export function _createDbWith(Ctor: BunSqlCtor, config: DbConfig): Db {
|
|
284
|
-
if (!config || typeof config.url !== "string" || config.url.length === 0) {
|
|
285
|
-
throw new TypeError(
|
|
286
|
-
"[@mandujs/core/db] createDb: 'url' is required and must be a non-empty string.",
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const provider: SqlProvider = config.provider ?? detectProvider(config.url);
|
|
291
|
-
const max = config.max ?? defaultMax(provider);
|
|
292
|
-
|
|
293
|
-
// Compose Bun.SQL options. The user-supplied `options` bag is merged
|
|
294
|
-
// FIRST so our authoritative fields win.
|
|
295
|
-
//
|
|
296
|
-
// Why per-provider options: when Bun.SQL receives an options OBJECT with
|
|
297
|
-
// a `url` property, it does NOT parse the URL into connection fields —
|
|
298
|
-
// it treats it as metadata and defaults to `:memory:` for SQLite (and
|
|
299
|
-
// localhost/5432 for Postgres). To make URL-based config actually work
|
|
300
|
-
// through an options object, we translate ourselves.
|
|
301
|
-
//
|
|
302
|
-
// - SQLite: pull the path out of `sqlite:` / `sqlite://` into `filename`.
|
|
303
|
-
// - Postgres / MySQL: parse the URL into hostname / port / credentials /
|
|
304
|
-
// database — Bun accepts these fields directly and applies its own
|
|
305
|
-
// defaulting logic on top.
|
|
306
|
-
const composed: BunSqlOptions = {
|
|
307
|
-
...(config.options ?? {}),
|
|
308
|
-
adapter: providerToAdapter(provider),
|
|
309
|
-
max,
|
|
310
|
-
};
|
|
311
|
-
applyUrlToOptions(composed, provider, config.url);
|
|
312
|
-
|
|
313
|
-
const bunSql = new Ctor(composed);
|
|
314
|
-
return buildDbHandle(bunSql, provider);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Translates a connection URL into provider-specific `Bun.SQL` options.
|
|
319
|
-
* Mutates `opts` in place (the caller already has a fresh object).
|
|
320
|
-
*
|
|
321
|
-
* @internal
|
|
322
|
-
*/
|
|
323
|
-
function applyUrlToOptions(
|
|
324
|
-
opts: BunSqlOptions,
|
|
325
|
-
provider: SqlProvider,
|
|
326
|
-
url: string,
|
|
327
|
-
): void {
|
|
328
|
-
if (provider === "sqlite") {
|
|
329
|
-
// Accept "sqlite::memory:", "sqlite:<path>", and "sqlite://<path>".
|
|
330
|
-
// The first is a special in-memory marker; everything after the scheme
|
|
331
|
-
// is the filename.
|
|
332
|
-
const rest = stripScheme(url);
|
|
333
|
-
opts.filename = rest === ":memory:" ? ":memory:" : rest;
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
// Postgres / MySQL: delegate to Bun by passing the URL string directly as
|
|
337
|
-
// an extra field. Bun's constructor accepts `url` AND parses it when no
|
|
338
|
-
// `hostname` is provided alongside — verified in 1.3.12. We also keep the
|
|
339
|
-
// original URL on the options so advanced logging can read it back.
|
|
340
|
-
opts.url = url;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Strips the leading `scheme:` (or `scheme://`) from a URL. Returns the
|
|
345
|
-
* portion Bun uses as the connection target — for SQLite that's the file
|
|
346
|
-
* path, for Postgres that's `user:pass@host:port/db`.
|
|
347
|
-
*/
|
|
348
|
-
function stripScheme(url: string): string {
|
|
349
|
-
// `sqlite::memory:` → `:memory:`
|
|
350
|
-
// `sqlite://./data.db` → `./data.db`
|
|
351
|
-
// `sqlite:./data.db` → `./data.db`
|
|
352
|
-
// `sqlite://C:\path\to\db` → `C:\path\to\db`
|
|
353
|
-
const schemeMatch = /^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/)?/.exec(url);
|
|
354
|
-
if (!schemeMatch) return url;
|
|
355
|
-
return url.slice(schemeMatch[0].length);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Wraps a Bun.SQL instance (top-level or transaction-scoped) with the
|
|
360
|
-
* public `Db` shape. Shared by `_createDbWith` and `transaction()`.
|
|
361
|
-
*/
|
|
362
|
-
function buildDbHandle(bunSql: BunSqlInstance, provider: SqlProvider): Db {
|
|
363
|
-
// Closed flag tracked per-handle. A transaction-scoped handle inherits
|
|
364
|
-
// the outer pool's close state transitively (Bun.SQL errors itself), but
|
|
365
|
-
// we also short-circuit here so we can return the canonical message.
|
|
366
|
-
let closed = false;
|
|
367
|
-
|
|
368
|
-
// Callable core: forward the tagged-template call straight to Bun.SQL,
|
|
369
|
-
// but translate post-close failures into our uniform error.
|
|
370
|
-
async function call<T extends Row = Row>(
|
|
371
|
-
strings: TemplateStringsArray,
|
|
372
|
-
...values: unknown[]
|
|
373
|
-
): Promise<T[]> {
|
|
374
|
-
if (closed) {
|
|
375
|
-
throw new Error(POOL_CLOSED_MESSAGE);
|
|
376
|
-
}
|
|
377
|
-
try {
|
|
378
|
-
// `bunSql` is itself a tagged-template callable; pass through verbatim.
|
|
379
|
-
const result = await bunSql<T>(strings, ...values);
|
|
380
|
-
// Bun.SQL returns an array-like with metadata props (count/command/…).
|
|
381
|
-
// Coerce to a plain array so consumers don't accidentally couple to
|
|
382
|
-
// those fields through this wrapper's public surface.
|
|
383
|
-
return Array.from(result) as T[];
|
|
384
|
-
} catch (err) {
|
|
385
|
-
if (isPoolClosedError(err)) {
|
|
386
|
-
throw new Error(POOL_CLOSED_MESSAGE);
|
|
387
|
-
}
|
|
388
|
-
throw err;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Function.prototype trick: make `call` itself the Db object by attaching
|
|
393
|
-
// the methods. This preserves the tagged-template call signature while
|
|
394
|
-
// satisfying the attached-methods part of the interface.
|
|
395
|
-
const db = call as unknown as Db;
|
|
396
|
-
|
|
397
|
-
Object.defineProperty(db, "provider", {
|
|
398
|
-
value: provider,
|
|
399
|
-
enumerable: true,
|
|
400
|
-
writable: false,
|
|
401
|
-
configurable: false,
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
(db as { one: Db["one"] }).one = async function one<T extends Row = Row>(
|
|
405
|
-
strings: TemplateStringsArray,
|
|
406
|
-
...values: unknown[]
|
|
407
|
-
): Promise<T | null> {
|
|
408
|
-
const rows = await call<T>(strings, ...values);
|
|
409
|
-
if (rows.length === 0) return null;
|
|
410
|
-
if (rows.length === 1) return rows[0] as T;
|
|
411
|
-
throw new Error(
|
|
412
|
-
`[@mandujs/core/db] one(): expected 0 or 1 row, got ${rows.length}.`,
|
|
413
|
-
);
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
(db as { transaction: Db["transaction"] }).transaction =
|
|
417
|
-
async function transaction<R>(fn: (tx: Db) => Promise<R>): Promise<R> {
|
|
418
|
-
if (closed) {
|
|
419
|
-
throw new Error(POOL_CLOSED_MESSAGE);
|
|
420
|
-
}
|
|
421
|
-
// Delegate to Bun.SQL's native begin(). The inner `tx` is a Bun.SQL
|
|
422
|
-
// handle bound to the active transaction; we wrap it with the same
|
|
423
|
-
// Db shape so user callbacks get a consistent API.
|
|
424
|
-
return await bunSql.begin(async (innerBunSql) => {
|
|
425
|
-
const innerDb = buildDbHandle(innerBunSql, provider);
|
|
426
|
-
return await fn(innerDb);
|
|
427
|
-
});
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
(db as { close: Db["close"] }).close = async function close(): Promise<void> {
|
|
431
|
-
if (closed) return; // idempotent
|
|
432
|
-
closed = true;
|
|
433
|
-
try {
|
|
434
|
-
await bunSql.close();
|
|
435
|
-
} catch (err) {
|
|
436
|
-
// Bun.SQL can throw if already-closed under the hood (mostly from a
|
|
437
|
-
// racing concurrent close). We already flipped our flag so subsequent
|
|
438
|
-
// queries reject cleanly — swallow this one.
|
|
439
|
-
if (isPoolClosedError(err)) return;
|
|
440
|
-
throw err;
|
|
441
|
-
}
|
|
442
|
-
};
|
|
443
|
-
|
|
444
|
-
return db;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Creates a {@link Db} handle backed by `Bun.SQL`. The Bun runtime probe
|
|
449
|
-
* is lazy — `createDb(...)` itself does not throw when Bun.SQL is missing;
|
|
450
|
-
* the first query (or `.close()`) does.
|
|
451
|
-
*
|
|
452
|
-
* @throws {TypeError} when `config.url` is missing or empty.
|
|
453
|
-
*/
|
|
454
|
-
export function createDb(config: DbConfig): Db {
|
|
455
|
-
// Up-front config validation. We check `url` here (not only in
|
|
456
|
-
// `_createDbWith`) so the error fires at construction time — matches
|
|
457
|
-
// the TypeError contract the public API documents.
|
|
458
|
-
if (!config || typeof config.url !== "string" || config.url.length === 0) {
|
|
459
|
-
throw new TypeError(
|
|
460
|
-
"[@mandujs/core/db] createDb: 'url' is required and must be a non-empty string.",
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Lazy probe: we defer the `Bun.SQL` lookup to first query by capturing
|
|
465
|
-
// a thunk here. In practice, Bun.SQL's constructor itself is cheap and
|
|
466
|
-
// doesn't open a connection (only `connect()` / first query do), so
|
|
467
|
-
// constructing the ctor-facing shape up-front would be fine — but we
|
|
468
|
-
// honor the precedent set by scheduler/storage.s3: the runtime error
|
|
469
|
-
// fires on the first real call, with a version-specific message.
|
|
470
|
-
//
|
|
471
|
-
// We still need to *return* a Db now, so call through a forwarding
|
|
472
|
-
// function that probes on demand.
|
|
473
|
-
let real: Db | null = null;
|
|
474
|
-
function materialize(): Db {
|
|
475
|
-
if (real) return real;
|
|
476
|
-
real = _createDbWith(getBunSqlCtor(), config);
|
|
477
|
-
return real;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const forward = async function forwardCall<T extends Row = Row>(
|
|
481
|
-
strings: TemplateStringsArray,
|
|
482
|
-
...values: unknown[]
|
|
483
|
-
): Promise<T[]> {
|
|
484
|
-
return await materialize()<T>(strings, ...values);
|
|
485
|
-
} as unknown as Db;
|
|
486
|
-
|
|
487
|
-
const provider: SqlProvider = config.provider ?? detectProvider(config.url);
|
|
488
|
-
Object.defineProperty(forward, "provider", {
|
|
489
|
-
value: provider,
|
|
490
|
-
enumerable: true,
|
|
491
|
-
writable: false,
|
|
492
|
-
configurable: false,
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
(forward as { one: Db["one"] }).one = async function one<T extends Row = Row>(
|
|
496
|
-
strings: TemplateStringsArray,
|
|
497
|
-
...values: unknown[]
|
|
498
|
-
): Promise<T | null> {
|
|
499
|
-
return await materialize().one<T>(strings, ...values);
|
|
500
|
-
};
|
|
501
|
-
(forward as { transaction: Db["transaction"] }).transaction =
|
|
502
|
-
async function transaction<R>(fn: (tx: Db) => Promise<R>): Promise<R> {
|
|
503
|
-
return await materialize().transaction(fn);
|
|
504
|
-
};
|
|
505
|
-
(forward as { close: Db["close"] }).close = async function close(): Promise<void> {
|
|
506
|
-
// If no query ever ran, materialize() was never called — nothing to
|
|
507
|
-
// close. Only materialize + close if a real handle exists.
|
|
508
|
-
if (!real) return;
|
|
509
|
-
await real.close();
|
|
510
|
-
};
|
|
511
|
-
|
|
512
|
-
return forward;
|
|
513
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/db
|
|
3
|
+
*
|
|
4
|
+
* Thin, production-grade wrapper around **native `Bun.SQL`** (Bun 1.3.x).
|
|
5
|
+
* Provides a callable tagged-template handle with `.one()`, `.transaction()`,
|
|
6
|
+
* and `.close()` affordances while preserving Bun.sql's parameter-safe
|
|
7
|
+
* placeholder semantics end-to-end.
|
|
8
|
+
*
|
|
9
|
+
* ## Providers
|
|
10
|
+
*
|
|
11
|
+
* - `postgres://…` / `postgresql://…` → Postgres
|
|
12
|
+
* - `mysql://…` → MySQL
|
|
13
|
+
* - `sqlite::memory:` / `sqlite:./file.db` / `sqlite://./file.db` → SQLite
|
|
14
|
+
*
|
|
15
|
+
* The `provider` config key overrides URL-based detection when the scheme
|
|
16
|
+
* is ambiguous. No other DB engines are supported — Bun.SQL itself accepts
|
|
17
|
+
* `postgres`, `sqlite`, `mysql`, and `mariadb` (aliased to mysql here).
|
|
18
|
+
*
|
|
19
|
+
* ## Why a wrapper
|
|
20
|
+
*
|
|
21
|
+
* Bun.SQL is already excellent. What this module adds:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Provider detection** — consistent scheme parsing; consumers read
|
|
24
|
+
* `db.provider` instead of sniffing `Bun.SQL.options` themselves.
|
|
25
|
+
* 2. **`.one()` helper** — zero-or-one-row query with clear errors for
|
|
26
|
+
* unexpected multi-row results. (Bun.SQL's raw `[0]` access silently
|
|
27
|
+
* drops extra rows.)
|
|
28
|
+
* 3. **Typed transactions** — `tx` inside `transaction(fn)` is the same
|
|
29
|
+
* `Db` shape as the outer handle (Bun.SQL's `begin()` returns a
|
|
30
|
+
* callable without `.one` / `.transaction` / `.close`).
|
|
31
|
+
* 4. **Lazy runtime probe** — `createDb()` never throws when Bun.SQL is
|
|
32
|
+
* unavailable; the first query does, with a version-specific message.
|
|
33
|
+
*
|
|
34
|
+
* ## Parameter binding
|
|
35
|
+
*
|
|
36
|
+
* Placeholders are passed through Bun.SQL's native parameter binding —
|
|
37
|
+
* never string-concatenated into the SQL text. Example:
|
|
38
|
+
*
|
|
39
|
+
* ```ts
|
|
40
|
+
* const user = await db.one<User>`SELECT * FROM users WHERE name = ${name}`;
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* The `${name}` is routed as a bound parameter even if it contains `'`,
|
|
44
|
+
* `--`, or other SQL metacharacters. There is no safe "unsafe interpolate"
|
|
45
|
+
* escape hatch on the public surface — use Bun.SQL directly if you need
|
|
46
|
+
* `sql.unsafe()` semantics.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* import { createDb } from "@mandujs/core/db";
|
|
51
|
+
*
|
|
52
|
+
* const db = createDb({ url: "postgres://user:pass@localhost/app" });
|
|
53
|
+
*
|
|
54
|
+
* await db`CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)`;
|
|
55
|
+
* await db`INSERT INTO users (name) VALUES (${"alice"})`;
|
|
56
|
+
*
|
|
57
|
+
* const one = await db.one<{ id: number; name: string }>`
|
|
58
|
+
* SELECT id, name FROM users WHERE name = ${"alice"}
|
|
59
|
+
* `;
|
|
60
|
+
*
|
|
61
|
+
* await db.transaction(async (tx) => {
|
|
62
|
+
* await tx`INSERT INTO users (name) VALUES (${"bob"})`;
|
|
63
|
+
* await tx`INSERT INTO users (name) VALUES (${"carol"})`;
|
|
64
|
+
* });
|
|
65
|
+
*
|
|
66
|
+
* await db.close();
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @module db
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
// ─── Public types ───────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** A single query result row. */
|
|
75
|
+
export type Row = Record<string, unknown>;
|
|
76
|
+
|
|
77
|
+
/** The three providers this wrapper supports. */
|
|
78
|
+
export type SqlProvider = "postgres" | "mysql" | "sqlite";
|
|
79
|
+
|
|
80
|
+
/** Configuration for {@link createDb}. */
|
|
81
|
+
export interface DbConfig {
|
|
82
|
+
/**
|
|
83
|
+
* Connection URL.
|
|
84
|
+
*
|
|
85
|
+
* - `sqlite::memory:` — in-memory SQLite
|
|
86
|
+
* - `sqlite:./data.db` / `sqlite://./data.db` — file-backed SQLite
|
|
87
|
+
* - `postgres://user:pass@host:5432/db` (or `postgresql://…`)
|
|
88
|
+
* - `mysql://user:pass@host:3306/db`
|
|
89
|
+
*/
|
|
90
|
+
url: string;
|
|
91
|
+
/**
|
|
92
|
+
* Override provider detection. Useful when embedding a non-standard URL
|
|
93
|
+
* scheme (e.g., a secrets manager placeholder the user rewrites at boot).
|
|
94
|
+
* When set, wins over URL-scheme sniffing.
|
|
95
|
+
*/
|
|
96
|
+
provider?: SqlProvider;
|
|
97
|
+
/**
|
|
98
|
+
* Max concurrent connections. Default: `10` for Postgres/MySQL, `1` for
|
|
99
|
+
* SQLite (SQLite is serialised by the engine; more than one connection
|
|
100
|
+
* just queues inside Bun).
|
|
101
|
+
*/
|
|
102
|
+
max?: number;
|
|
103
|
+
/**
|
|
104
|
+
* Additional options forwarded to the `Bun.SQL` constructor. Escape hatch
|
|
105
|
+
* for advanced configuration — `ssl`, `idleTimeout`, `tls`, `bigint`, etc.
|
|
106
|
+
*
|
|
107
|
+
* The keys `url`, `adapter`, and `max` from this object are ignored to
|
|
108
|
+
* keep public surface authoritative.
|
|
109
|
+
*/
|
|
110
|
+
options?: Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A database handle. Invoking it as a **tagged template** runs a query and
|
|
115
|
+
* returns the full result array. The attached methods support one-shot
|
|
116
|
+
* reads, transactions, and shutdown.
|
|
117
|
+
*/
|
|
118
|
+
export interface Db {
|
|
119
|
+
/**
|
|
120
|
+
* Tagged-template query. Values are bound as parameters, never
|
|
121
|
+
* interpolated as SQL text.
|
|
122
|
+
*/
|
|
123
|
+
<T extends Row = Row>(
|
|
124
|
+
strings: TemplateStringsArray,
|
|
125
|
+
...values: unknown[]
|
|
126
|
+
): Promise<T[]>;
|
|
127
|
+
|
|
128
|
+
/** Detected provider (`"postgres"`, `"mysql"`, or `"sqlite"`). */
|
|
129
|
+
readonly provider: SqlProvider;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Runs the query and returns at most one row.
|
|
133
|
+
*
|
|
134
|
+
* - `0` rows → resolves to `null`
|
|
135
|
+
* - `1` row → resolves to that row
|
|
136
|
+
* - `>= 2` rows → rejects with an Error naming the actual count
|
|
137
|
+
*
|
|
138
|
+
* Use when the query is expected to match at most one record (lookup by
|
|
139
|
+
* unique key, `LIMIT 1`, etc.).
|
|
140
|
+
*/
|
|
141
|
+
one<T extends Row = Row>(
|
|
142
|
+
strings: TemplateStringsArray,
|
|
143
|
+
...values: unknown[]
|
|
144
|
+
): Promise<T | null>;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Runs `fn` inside a transaction. Commits if `fn` resolves; rolls back
|
|
148
|
+
* if it throws (and re-throws the original error).
|
|
149
|
+
*
|
|
150
|
+
* Nested transactions are NOT supported — call `transaction` from within
|
|
151
|
+
* another `transaction` and you'll get an error surfaced by Bun.SQL
|
|
152
|
+
* ("cannot call begin inside a transaction use savepoint() instead").
|
|
153
|
+
* Use savepoints via raw Bun.SQL if you need nesting.
|
|
154
|
+
*/
|
|
155
|
+
transaction<R>(fn: (tx: Db) => Promise<R>): Promise<R>;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Closes the connection pool. Subsequent queries reject with a clear
|
|
159
|
+
* "pool closed" error. Calling `close()` twice is a no-op (idempotent).
|
|
160
|
+
*/
|
|
161
|
+
close(): Promise<void>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Bun runtime surface (structural; no `any`) ─────────────────────────────
|
|
165
|
+
|
|
166
|
+
/** Shape of the options object Bun.SQL accepts. */
|
|
167
|
+
interface BunSqlOptions {
|
|
168
|
+
url?: string;
|
|
169
|
+
adapter?: string;
|
|
170
|
+
max?: number;
|
|
171
|
+
filename?: string;
|
|
172
|
+
hostname?: string;
|
|
173
|
+
port?: number;
|
|
174
|
+
username?: string;
|
|
175
|
+
password?: string;
|
|
176
|
+
database?: string;
|
|
177
|
+
[key: string]: unknown;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A Bun.SQL instance — itself a callable tagged-template function with
|
|
182
|
+
* methods attached. We only model the subset we actually use.
|
|
183
|
+
*/
|
|
184
|
+
interface BunSqlInstance {
|
|
185
|
+
<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<
|
|
186
|
+
T[] & { count?: number; command?: string }
|
|
187
|
+
>;
|
|
188
|
+
begin<R>(fn: (tx: BunSqlInstance) => Promise<R>): Promise<R>;
|
|
189
|
+
close(): Promise<void>;
|
|
190
|
+
readonly options?: BunSqlOptions;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Constructor surface — `Bun.SQL` is a class; we only need the `new`
|
|
195
|
+
* signature. Accepts either a URL string or a full options object.
|
|
196
|
+
*/
|
|
197
|
+
export type BunSqlCtor = new (
|
|
198
|
+
urlOrOptions: string | BunSqlOptions,
|
|
199
|
+
) => BunSqlInstance;
|
|
200
|
+
|
|
201
|
+
// ─── Provider detection ─────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Derives a {@link SqlProvider} from a connection URL. Throws a clear
|
|
205
|
+
* error for unsupported / ambiguous schemes so the call site fails early
|
|
206
|
+
* instead of passing a half-formed config to Bun.SQL.
|
|
207
|
+
*/
|
|
208
|
+
export function detectProvider(url: string): SqlProvider {
|
|
209
|
+
// Matches `sqlite::memory:`, `sqlite://path`, `sqlite:./file` — any
|
|
210
|
+
// "starts with sqlite:" variant. We intentionally accept the no-slash
|
|
211
|
+
// form because it's what Bun.SQL documents as canonical.
|
|
212
|
+
if (url.startsWith("sqlite:")) return "sqlite";
|
|
213
|
+
if (url.startsWith("postgres://") || url.startsWith("postgresql://")) {
|
|
214
|
+
return "postgres";
|
|
215
|
+
}
|
|
216
|
+
if (url.startsWith("mysql://") || url.startsWith("mariadb://")) {
|
|
217
|
+
return "mysql";
|
|
218
|
+
}
|
|
219
|
+
throw new Error(
|
|
220
|
+
`[@mandujs/core/db] Unable to detect provider from url: ${JSON.stringify(
|
|
221
|
+
url,
|
|
222
|
+
)}. Supported schemes: postgres://, postgresql://, mysql://, mariadb://, sqlite:. ` +
|
|
223
|
+
`Pass { provider: "postgres" | "mysql" | "sqlite" } to override.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Maps our {@link SqlProvider} to the adapter name Bun.SQL expects. */
|
|
228
|
+
function providerToAdapter(p: SqlProvider): string {
|
|
229
|
+
// Bun.SQL uses "postgres" / "sqlite" / "mysql" verbatim; no mapping fuzz.
|
|
230
|
+
return p;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Default `max` connections per provider. SQLite serialises at the engine. */
|
|
234
|
+
function defaultMax(p: SqlProvider): number {
|
|
235
|
+
return p === "sqlite" ? 1 : 10;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Bun runtime probe ──────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function getBunSqlCtor(): BunSqlCtor {
|
|
241
|
+
const g = globalThis as unknown as { Bun?: { SQL?: BunSqlCtor } };
|
|
242
|
+
if (!g.Bun || typeof g.Bun.SQL !== "function") {
|
|
243
|
+
throw new Error(
|
|
244
|
+
"[@mandujs/core/db] Bun.sql is unavailable — this module requires Bun runtime >= 1.3.x. " +
|
|
245
|
+
"Install/upgrade Bun: https://bun.com/docs/installation",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return g.Bun.SQL;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── Error helpers ──────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
const POOL_CLOSED_MESSAGE =
|
|
254
|
+
"[@mandujs/core/db] pool closed — query issued after Db.close().";
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Structural check for "connection/pool closed" errors that Bun.SQL raises
|
|
258
|
+
* after `.close()`. Bun surfaces these as `SQLiteError` / `PostgresError` /
|
|
259
|
+
* `MySQLError` with a `code` ending in `CLOSED`.
|
|
260
|
+
*/
|
|
261
|
+
function isPoolClosedError(err: unknown): boolean {
|
|
262
|
+
if (!err || typeof err !== "object") return false;
|
|
263
|
+
const e = err as { code?: unknown; message?: unknown };
|
|
264
|
+
if (typeof e.code === "string" && e.code.includes("CLOSED")) return true;
|
|
265
|
+
if (
|
|
266
|
+
typeof e.message === "string" &&
|
|
267
|
+
/(connection|pool)\s+closed/i.test(e.message)
|
|
268
|
+
) {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Internal factory accepting an injectable `Bun.SQL` constructor — used by
|
|
278
|
+
* unit tests to swap in a fake implementation. Production callers use
|
|
279
|
+
* {@link createDb}, which binds this to `Bun.SQL`.
|
|
280
|
+
*
|
|
281
|
+
* @internal
|
|
282
|
+
*/
|
|
283
|
+
export function _createDbWith(Ctor: BunSqlCtor, config: DbConfig): Db {
|
|
284
|
+
if (!config || typeof config.url !== "string" || config.url.length === 0) {
|
|
285
|
+
throw new TypeError(
|
|
286
|
+
"[@mandujs/core/db] createDb: 'url' is required and must be a non-empty string.",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const provider: SqlProvider = config.provider ?? detectProvider(config.url);
|
|
291
|
+
const max = config.max ?? defaultMax(provider);
|
|
292
|
+
|
|
293
|
+
// Compose Bun.SQL options. The user-supplied `options` bag is merged
|
|
294
|
+
// FIRST so our authoritative fields win.
|
|
295
|
+
//
|
|
296
|
+
// Why per-provider options: when Bun.SQL receives an options OBJECT with
|
|
297
|
+
// a `url` property, it does NOT parse the URL into connection fields —
|
|
298
|
+
// it treats it as metadata and defaults to `:memory:` for SQLite (and
|
|
299
|
+
// localhost/5432 for Postgres). To make URL-based config actually work
|
|
300
|
+
// through an options object, we translate ourselves.
|
|
301
|
+
//
|
|
302
|
+
// - SQLite: pull the path out of `sqlite:` / `sqlite://` into `filename`.
|
|
303
|
+
// - Postgres / MySQL: parse the URL into hostname / port / credentials /
|
|
304
|
+
// database — Bun accepts these fields directly and applies its own
|
|
305
|
+
// defaulting logic on top.
|
|
306
|
+
const composed: BunSqlOptions = {
|
|
307
|
+
...(config.options ?? {}),
|
|
308
|
+
adapter: providerToAdapter(provider),
|
|
309
|
+
max,
|
|
310
|
+
};
|
|
311
|
+
applyUrlToOptions(composed, provider, config.url);
|
|
312
|
+
|
|
313
|
+
const bunSql = new Ctor(composed);
|
|
314
|
+
return buildDbHandle(bunSql, provider);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Translates a connection URL into provider-specific `Bun.SQL` options.
|
|
319
|
+
* Mutates `opts` in place (the caller already has a fresh object).
|
|
320
|
+
*
|
|
321
|
+
* @internal
|
|
322
|
+
*/
|
|
323
|
+
function applyUrlToOptions(
|
|
324
|
+
opts: BunSqlOptions,
|
|
325
|
+
provider: SqlProvider,
|
|
326
|
+
url: string,
|
|
327
|
+
): void {
|
|
328
|
+
if (provider === "sqlite") {
|
|
329
|
+
// Accept "sqlite::memory:", "sqlite:<path>", and "sqlite://<path>".
|
|
330
|
+
// The first is a special in-memory marker; everything after the scheme
|
|
331
|
+
// is the filename.
|
|
332
|
+
const rest = stripScheme(url);
|
|
333
|
+
opts.filename = rest === ":memory:" ? ":memory:" : rest;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// Postgres / MySQL: delegate to Bun by passing the URL string directly as
|
|
337
|
+
// an extra field. Bun's constructor accepts `url` AND parses it when no
|
|
338
|
+
// `hostname` is provided alongside — verified in 1.3.12. We also keep the
|
|
339
|
+
// original URL on the options so advanced logging can read it back.
|
|
340
|
+
opts.url = url;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Strips the leading `scheme:` (or `scheme://`) from a URL. Returns the
|
|
345
|
+
* portion Bun uses as the connection target — for SQLite that's the file
|
|
346
|
+
* path, for Postgres that's `user:pass@host:port/db`.
|
|
347
|
+
*/
|
|
348
|
+
function stripScheme(url: string): string {
|
|
349
|
+
// `sqlite::memory:` → `:memory:`
|
|
350
|
+
// `sqlite://./data.db` → `./data.db`
|
|
351
|
+
// `sqlite:./data.db` → `./data.db`
|
|
352
|
+
// `sqlite://C:\path\to\db` → `C:\path\to\db`
|
|
353
|
+
const schemeMatch = /^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/)?/.exec(url);
|
|
354
|
+
if (!schemeMatch) return url;
|
|
355
|
+
return url.slice(schemeMatch[0].length);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Wraps a Bun.SQL instance (top-level or transaction-scoped) with the
|
|
360
|
+
* public `Db` shape. Shared by `_createDbWith` and `transaction()`.
|
|
361
|
+
*/
|
|
362
|
+
function buildDbHandle(bunSql: BunSqlInstance, provider: SqlProvider): Db {
|
|
363
|
+
// Closed flag tracked per-handle. A transaction-scoped handle inherits
|
|
364
|
+
// the outer pool's close state transitively (Bun.SQL errors itself), but
|
|
365
|
+
// we also short-circuit here so we can return the canonical message.
|
|
366
|
+
let closed = false;
|
|
367
|
+
|
|
368
|
+
// Callable core: forward the tagged-template call straight to Bun.SQL,
|
|
369
|
+
// but translate post-close failures into our uniform error.
|
|
370
|
+
async function call<T extends Row = Row>(
|
|
371
|
+
strings: TemplateStringsArray,
|
|
372
|
+
...values: unknown[]
|
|
373
|
+
): Promise<T[]> {
|
|
374
|
+
if (closed) {
|
|
375
|
+
throw new Error(POOL_CLOSED_MESSAGE);
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
// `bunSql` is itself a tagged-template callable; pass through verbatim.
|
|
379
|
+
const result = await bunSql<T>(strings, ...values);
|
|
380
|
+
// Bun.SQL returns an array-like with metadata props (count/command/…).
|
|
381
|
+
// Coerce to a plain array so consumers don't accidentally couple to
|
|
382
|
+
// those fields through this wrapper's public surface.
|
|
383
|
+
return Array.from(result) as T[];
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (isPoolClosedError(err)) {
|
|
386
|
+
throw new Error(POOL_CLOSED_MESSAGE, { cause: err });
|
|
387
|
+
}
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Function.prototype trick: make `call` itself the Db object by attaching
|
|
393
|
+
// the methods. This preserves the tagged-template call signature while
|
|
394
|
+
// satisfying the attached-methods part of the interface.
|
|
395
|
+
const db = call as unknown as Db;
|
|
396
|
+
|
|
397
|
+
Object.defineProperty(db, "provider", {
|
|
398
|
+
value: provider,
|
|
399
|
+
enumerable: true,
|
|
400
|
+
writable: false,
|
|
401
|
+
configurable: false,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
(db as { one: Db["one"] }).one = async function one<T extends Row = Row>(
|
|
405
|
+
strings: TemplateStringsArray,
|
|
406
|
+
...values: unknown[]
|
|
407
|
+
): Promise<T | null> {
|
|
408
|
+
const rows = await call<T>(strings, ...values);
|
|
409
|
+
if (rows.length === 0) return null;
|
|
410
|
+
if (rows.length === 1) return rows[0] as T;
|
|
411
|
+
throw new Error(
|
|
412
|
+
`[@mandujs/core/db] one(): expected 0 or 1 row, got ${rows.length}.`,
|
|
413
|
+
);
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
(db as { transaction: Db["transaction"] }).transaction =
|
|
417
|
+
async function transaction<R>(fn: (tx: Db) => Promise<R>): Promise<R> {
|
|
418
|
+
if (closed) {
|
|
419
|
+
throw new Error(POOL_CLOSED_MESSAGE);
|
|
420
|
+
}
|
|
421
|
+
// Delegate to Bun.SQL's native begin(). The inner `tx` is a Bun.SQL
|
|
422
|
+
// handle bound to the active transaction; we wrap it with the same
|
|
423
|
+
// Db shape so user callbacks get a consistent API.
|
|
424
|
+
return await bunSql.begin(async (innerBunSql) => {
|
|
425
|
+
const innerDb = buildDbHandle(innerBunSql, provider);
|
|
426
|
+
return await fn(innerDb);
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
(db as { close: Db["close"] }).close = async function close(): Promise<void> {
|
|
431
|
+
if (closed) return; // idempotent
|
|
432
|
+
closed = true;
|
|
433
|
+
try {
|
|
434
|
+
await bunSql.close();
|
|
435
|
+
} catch (err) {
|
|
436
|
+
// Bun.SQL can throw if already-closed under the hood (mostly from a
|
|
437
|
+
// racing concurrent close). We already flipped our flag so subsequent
|
|
438
|
+
// queries reject cleanly — swallow this one.
|
|
439
|
+
if (isPoolClosedError(err)) return;
|
|
440
|
+
throw err;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
return db;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Creates a {@link Db} handle backed by `Bun.SQL`. The Bun runtime probe
|
|
449
|
+
* is lazy — `createDb(...)` itself does not throw when Bun.SQL is missing;
|
|
450
|
+
* the first query (or `.close()`) does.
|
|
451
|
+
*
|
|
452
|
+
* @throws {TypeError} when `config.url` is missing or empty.
|
|
453
|
+
*/
|
|
454
|
+
export function createDb(config: DbConfig): Db {
|
|
455
|
+
// Up-front config validation. We check `url` here (not only in
|
|
456
|
+
// `_createDbWith`) so the error fires at construction time — matches
|
|
457
|
+
// the TypeError contract the public API documents.
|
|
458
|
+
if (!config || typeof config.url !== "string" || config.url.length === 0) {
|
|
459
|
+
throw new TypeError(
|
|
460
|
+
"[@mandujs/core/db] createDb: 'url' is required and must be a non-empty string.",
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Lazy probe: we defer the `Bun.SQL` lookup to first query by capturing
|
|
465
|
+
// a thunk here. In practice, Bun.SQL's constructor itself is cheap and
|
|
466
|
+
// doesn't open a connection (only `connect()` / first query do), so
|
|
467
|
+
// constructing the ctor-facing shape up-front would be fine — but we
|
|
468
|
+
// honor the precedent set by scheduler/storage.s3: the runtime error
|
|
469
|
+
// fires on the first real call, with a version-specific message.
|
|
470
|
+
//
|
|
471
|
+
// We still need to *return* a Db now, so call through a forwarding
|
|
472
|
+
// function that probes on demand.
|
|
473
|
+
let real: Db | null = null;
|
|
474
|
+
function materialize(): Db {
|
|
475
|
+
if (real) return real;
|
|
476
|
+
real = _createDbWith(getBunSqlCtor(), config);
|
|
477
|
+
return real;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const forward = async function forwardCall<T extends Row = Row>(
|
|
481
|
+
strings: TemplateStringsArray,
|
|
482
|
+
...values: unknown[]
|
|
483
|
+
): Promise<T[]> {
|
|
484
|
+
return await materialize()<T>(strings, ...values);
|
|
485
|
+
} as unknown as Db;
|
|
486
|
+
|
|
487
|
+
const provider: SqlProvider = config.provider ?? detectProvider(config.url);
|
|
488
|
+
Object.defineProperty(forward, "provider", {
|
|
489
|
+
value: provider,
|
|
490
|
+
enumerable: true,
|
|
491
|
+
writable: false,
|
|
492
|
+
configurable: false,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
(forward as { one: Db["one"] }).one = async function one<T extends Row = Row>(
|
|
496
|
+
strings: TemplateStringsArray,
|
|
497
|
+
...values: unknown[]
|
|
498
|
+
): Promise<T | null> {
|
|
499
|
+
return await materialize().one<T>(strings, ...values);
|
|
500
|
+
};
|
|
501
|
+
(forward as { transaction: Db["transaction"] }).transaction =
|
|
502
|
+
async function transaction<R>(fn: (tx: Db) => Promise<R>): Promise<R> {
|
|
503
|
+
return await materialize().transaction(fn);
|
|
504
|
+
};
|
|
505
|
+
(forward as { close: Db["close"] }).close = async function close(): Promise<void> {
|
|
506
|
+
// If no query ever ran, materialize() was never called — nothing to
|
|
507
|
+
// close. Only materialize + close if a real handle exists.
|
|
508
|
+
if (!real) return;
|
|
509
|
+
await real.close();
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
return forward;
|
|
513
|
+
}
|