@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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. 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
+ }