@mandujs/core 0.20.10 → 0.22.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 (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -0,0 +1,485 @@
1
+ /**
2
+ * @mandujs/core/db — unit tests
3
+ *
4
+ * These tests never open a real database connection. They substitute a
5
+ * controllable in-memory fake for `Bun.SQL` via the internal
6
+ * `_createDbWith` entry point, then assert that our wrapper:
7
+ *
8
+ * - detects the provider correctly,
9
+ * - forwards tagged-template calls to Bun.SQL verbatim,
10
+ * - implements `.one()`, `.transaction()`, `.close()` semantics,
11
+ * - propagates post-close errors as the canonical message,
12
+ * - binds placeholder values as parameters (never string-interpolated).
13
+ *
14
+ * SQLite integration tests (hitting the real `Bun.SQL`) live in
15
+ * `packages/core/tests/db/db-sqlite.test.ts`.
16
+ */
17
+
18
+ import { describe, expect, it } from "bun:test";
19
+ import {
20
+ _createDbWith,
21
+ createDb,
22
+ detectProvider,
23
+ type BunSqlCtor,
24
+ type SqlProvider,
25
+ } from "../index";
26
+
27
+ // ─── Fake Bun.SQL ───────────────────────────────────────────────────────────
28
+
29
+ /** A captured (strings, values) tuple. */
30
+ interface CapturedCall {
31
+ /** The raw TemplateStringsArray — cloned to a plain array to make assertions readable. */
32
+ strings: readonly string[];
33
+ values: readonly unknown[];
34
+ }
35
+
36
+ interface FakeState {
37
+ ctorCalls: Array<Record<string, unknown>>;
38
+ calls: CapturedCall[];
39
+ /** `true` once `close()` has been invoked on any handle. */
40
+ closed: boolean;
41
+ closeCount: number;
42
+ /**
43
+ * If set, the next `call()` throws this error instead of returning rows.
44
+ * After the throw, the override is cleared unless `sticky` is true.
45
+ */
46
+ nextError: { err: Error; sticky: boolean } | null;
47
+ /** Rows returned by the next `call()` — pop-front queue. Default: `[]`. */
48
+ nextRowsQueue: unknown[][];
49
+ }
50
+
51
+ /** Create a fake Bun.SQL ctor + observable state. */
52
+ function createFakeCtor(): { Ctor: BunSqlCtor; state: FakeState } {
53
+ const state: FakeState = {
54
+ ctorCalls: [],
55
+ calls: [],
56
+ closed: false,
57
+ closeCount: 0,
58
+ nextError: null,
59
+ nextRowsQueue: [],
60
+ };
61
+
62
+ /**
63
+ * The inner builder — used both for the top-level handle and for
64
+ * transaction-scoped handles. Each is itself a callable tagged-template
65
+ * function with `.begin` and `.close` attached.
66
+ */
67
+ function makeFakeSqlInstance(isTx = false): unknown {
68
+ const call = (strings: TemplateStringsArray, ...values: unknown[]) => {
69
+ if (state.closed && !isTx) {
70
+ // Simulate Bun.SQL's post-close failure — our wrapper should catch
71
+ // this and rethrow with the canonical "pool closed" message.
72
+ const err = Object.assign(new Error("Connection closed"), {
73
+ code: "ERR_SQLITE_CONNECTION_CLOSED",
74
+ name: "SQLiteError",
75
+ });
76
+ return Promise.reject(err);
77
+ }
78
+ state.calls.push({
79
+ strings: Array.from(strings),
80
+ values,
81
+ });
82
+ if (state.nextError) {
83
+ const err = state.nextError.err;
84
+ if (!state.nextError.sticky) state.nextError = null;
85
+ return Promise.reject(err);
86
+ }
87
+ const rows = state.nextRowsQueue.shift() ?? [];
88
+ // Bun.SQL returns an array-like with extra metadata; we emulate an
89
+ // array and let `Array.from` in the wrapper coerce it. Plain array
90
+ // is a valid subset.
91
+ return Promise.resolve(rows);
92
+ };
93
+
94
+ const methods = {
95
+ begin: async <R>(
96
+ fn: (tx: unknown) => Promise<R>,
97
+ ): Promise<R> => {
98
+ const inner = makeFakeSqlInstance(true);
99
+ return await fn(inner);
100
+ },
101
+ close: async (): Promise<void> => {
102
+ state.closed = true;
103
+ state.closeCount += 1;
104
+ },
105
+ };
106
+
107
+ return Object.assign(call, methods);
108
+ }
109
+
110
+ class FakeBunSql {
111
+ constructor(config: Record<string, unknown>) {
112
+ state.ctorCalls.push(config);
113
+ // Return the callable instance — NOT a class instance. Returning a
114
+ // non-this value from a constructor replaces the default return.
115
+ return makeFakeSqlInstance(false) as FakeBunSql;
116
+ }
117
+ }
118
+
119
+ return {
120
+ Ctor: FakeBunSql as unknown as BunSqlCtor,
121
+ state,
122
+ };
123
+ }
124
+
125
+ // ─── detectProvider ────────────────────────────────────────────────────────
126
+
127
+ describe("@mandujs/core/db — detectProvider", () => {
128
+ it.each<[string, SqlProvider]>([
129
+ ["postgres://user:pass@host:5432/db", "postgres"],
130
+ ["postgresql://user:pass@host:5432/db", "postgres"],
131
+ ["mysql://user@host:3306/db", "mysql"],
132
+ ["mariadb://user@host/db", "mysql"],
133
+ ["sqlite::memory:", "sqlite"],
134
+ ["sqlite:./data.db", "sqlite"],
135
+ ["sqlite://./data.db", "sqlite"],
136
+ ])("maps %s to %s", (url, expected) => {
137
+ expect(detectProvider(url)).toBe(expected);
138
+ });
139
+
140
+ it("throws a clear error on unsupported schemes", () => {
141
+ expect(() => detectProvider("mongodb://host/db")).toThrow(
142
+ /Unable to detect provider/,
143
+ );
144
+ expect(() => detectProvider("mongodb://host/db")).toThrow(/mongodb/);
145
+ });
146
+
147
+ it("throws when URL has no recognized scheme", () => {
148
+ expect(() => detectProvider("not a url")).toThrow(/Unable to detect provider/);
149
+ });
150
+ });
151
+
152
+ // ─── createDb / _createDbWith: basic surface ───────────────────────────────
153
+
154
+ describe("@mandujs/core/db — createDb basics", () => {
155
+ it("returns a callable handle with .provider set to sqlite for sqlite URL", () => {
156
+ const db = createDb({ url: "sqlite::memory:" });
157
+ expect(typeof db).toBe("function");
158
+ expect(db.provider).toBe("sqlite");
159
+ expect(typeof db.one).toBe("function");
160
+ expect(typeof db.transaction).toBe("function");
161
+ expect(typeof db.close).toBe("function");
162
+ });
163
+
164
+ it("respects config.provider override when url scheme is ambiguous", () => {
165
+ const db = createDb({
166
+ url: "custom://placeholder-rewritten-at-boot",
167
+ provider: "sqlite",
168
+ });
169
+ expect(db.provider).toBe("sqlite");
170
+ });
171
+
172
+ it("throws TypeError when url is missing", () => {
173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
+ expect(() => createDb({ url: "" } as any)).toThrow(TypeError);
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ expect(() => _createDbWith({} as BunSqlCtor, { url: "" } as any)).toThrow(
177
+ TypeError,
178
+ );
179
+ });
180
+
181
+ it("throws a clear provider-detection error when url scheme is ambiguous and no override is set", () => {
182
+ expect(() => createDb({ url: "kafka://not-a-db" })).toThrow(
183
+ /Unable to detect provider/,
184
+ );
185
+ });
186
+ });
187
+
188
+ // ─── createDb: ctor forwarding ─────────────────────────────────────────────
189
+
190
+ describe("@mandujs/core/db — ctor forwarding", () => {
191
+ it("forwards url + detected adapter + default max (10 for postgres)", () => {
192
+ const { Ctor, state } = createFakeCtor();
193
+ _createDbWith(Ctor, { url: "postgres://u:p@h/db" });
194
+
195
+ expect(state.ctorCalls).toHaveLength(1);
196
+ expect(state.ctorCalls[0]!.url).toBe("postgres://u:p@h/db");
197
+ expect(state.ctorCalls[0]!.adapter).toBe("postgres");
198
+ expect(state.ctorCalls[0]!.max).toBe(10);
199
+ });
200
+
201
+ it("uses max=1 for sqlite by default", () => {
202
+ const { Ctor, state } = createFakeCtor();
203
+ _createDbWith(Ctor, { url: "sqlite::memory:" });
204
+ expect(state.ctorCalls[0]!.max).toBe(1);
205
+ });
206
+
207
+ it("uses max=10 for mysql by default", () => {
208
+ const { Ctor, state } = createFakeCtor();
209
+ _createDbWith(Ctor, { url: "mysql://u@h/db" });
210
+ expect(state.ctorCalls[0]!.max).toBe(10);
211
+ });
212
+
213
+ it("respects explicit config.max over provider defaults", () => {
214
+ const { Ctor, state } = createFakeCtor();
215
+ _createDbWith(Ctor, { url: "postgres://u:p@h/db", max: 42 });
216
+ expect(state.ctorCalls[0]!.max).toBe(42);
217
+ });
218
+
219
+ it("passes options bag through to Bun.SQL but does not let it override url/adapter/max", () => {
220
+ const { Ctor, state } = createFakeCtor();
221
+ _createDbWith(Ctor, {
222
+ url: "postgres://u:p@h/db",
223
+ max: 5,
224
+ options: {
225
+ // User tries to sneak in conflicting values; our authoritative
226
+ // fields must win to keep public surface deterministic.
227
+ url: "mysql://sneaky",
228
+ adapter: "mysql",
229
+ max: 99,
230
+ // Legitimate pass-through options:
231
+ ssl: "require",
232
+ idleTimeout: 30,
233
+ },
234
+ });
235
+
236
+ const call = state.ctorCalls[0]!;
237
+ expect(call.url).toBe("postgres://u:p@h/db");
238
+ expect(call.adapter).toBe("postgres");
239
+ expect(call.max).toBe(5);
240
+ expect(call.ssl).toBe("require");
241
+ expect(call.idleTimeout).toBe(30);
242
+ });
243
+ });
244
+
245
+ // ─── Tagged-template forwarding ────────────────────────────────────────────
246
+
247
+ describe("@mandujs/core/db — tagged template forwarding", () => {
248
+ it("forwards the full TemplateStringsArray and values to Bun.SQL", async () => {
249
+ const { Ctor, state } = createFakeCtor();
250
+ state.nextRowsQueue.push([{ id: 1, name: "alice" }]);
251
+
252
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
253
+ const name = "alice";
254
+ const id = 42;
255
+ const rows = await db`SELECT * FROM users WHERE id = ${id} AND name = ${name}`;
256
+
257
+ expect(state.calls).toHaveLength(1);
258
+ expect(Array.from(state.calls[0]!.strings)).toEqual([
259
+ "SELECT * FROM users WHERE id = ",
260
+ " AND name = ",
261
+ "",
262
+ ]);
263
+ expect(state.calls[0]!.values).toEqual([42, "alice"]);
264
+ expect(rows).toEqual([{ id: 1, name: "alice" }]);
265
+ });
266
+
267
+ it("binds values as parameters, never string-interpolated (injection safety)", async () => {
268
+ const { Ctor, state } = createFakeCtor();
269
+ state.nextRowsQueue.push([]);
270
+
271
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
272
+ const userInput = "'; DROP TABLE users; --";
273
+ await db`SELECT * FROM users WHERE name = ${userInput}`;
274
+
275
+ // The forwarded TemplateStringsArray must contain placeholders around
276
+ // the user-controlled value — NOT the value itself concatenated into
277
+ // the SQL. If our wrapper ever regressed to string-interpolation, the
278
+ // userInput would leak into strings[0] or strings[1].
279
+ const call = state.calls[0]!;
280
+ for (const s of call.strings) {
281
+ expect(s).not.toContain(userInput);
282
+ expect(s).not.toContain("DROP TABLE");
283
+ }
284
+ // And the value must be present in the bound-values array — exactly once.
285
+ expect(call.values).toEqual([userInput]);
286
+ });
287
+
288
+ it("returns a plain array (not Bun.SQL's array-like with .count/.command metadata)", async () => {
289
+ const { Ctor, state } = createFakeCtor();
290
+ // Simulate the array-like Bun.SQL returns in real life.
291
+ const fakeResult: { count: number; command: string } & Array<Record<string, unknown>> =
292
+ Object.assign([{ x: 1 }, { x: 2 }], { count: 2, command: "SELECT" });
293
+ state.nextRowsQueue.push(fakeResult);
294
+
295
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
296
+ const rows = await db`SELECT x FROM t`;
297
+
298
+ expect(Array.isArray(rows)).toBe(true);
299
+ expect(rows).toEqual([{ x: 1 }, { x: 2 }]);
300
+ // These metadata fields should NOT be exposed via our wrapper.
301
+ expect((rows as unknown as { count?: number }).count).toBeUndefined();
302
+ expect((rows as unknown as { command?: string }).command).toBeUndefined();
303
+ });
304
+ });
305
+
306
+ // ─── .one() ────────────────────────────────────────────────────────────────
307
+
308
+ describe("@mandujs/core/db — .one()", () => {
309
+ it("returns null when no rows match", async () => {
310
+ const { Ctor, state } = createFakeCtor();
311
+ state.nextRowsQueue.push([]);
312
+
313
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
314
+ const row = await db.one`SELECT * FROM users WHERE id = ${999}`;
315
+
316
+ expect(row).toBeNull();
317
+ });
318
+
319
+ it("returns the single row when exactly one matches", async () => {
320
+ const { Ctor, state } = createFakeCtor();
321
+ state.nextRowsQueue.push([{ id: 1, name: "alice" }]);
322
+
323
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
324
+ const row = await db.one<{ id: number; name: string }>`
325
+ SELECT * FROM users WHERE id = ${1}
326
+ `;
327
+
328
+ expect(row).toEqual({ id: 1, name: "alice" });
329
+ });
330
+
331
+ it("throws a clear error naming the count when multiple rows match", async () => {
332
+ const { Ctor, state } = createFakeCtor();
333
+ state.nextRowsQueue.push([{ id: 1 }, { id: 2 }, { id: 3 }]);
334
+
335
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
336
+
337
+ await expect(
338
+ db.one`SELECT * FROM users WHERE active = ${true}`,
339
+ ).rejects.toThrow(/expected 0 or 1 row, got 3/);
340
+ });
341
+ });
342
+
343
+ // ─── .transaction() ────────────────────────────────────────────────────────
344
+
345
+ describe("@mandujs/core/db — .transaction()", () => {
346
+ it("calls the user fn with a tx handle of the same Db shape and commits on resolve", async () => {
347
+ const { Ctor, state } = createFakeCtor();
348
+ state.nextRowsQueue.push([]); // INSERT 1
349
+ state.nextRowsQueue.push([]); // INSERT 2
350
+
351
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
352
+
353
+ const result = await db.transaction(async (tx) => {
354
+ expect(typeof tx).toBe("function");
355
+ expect(typeof tx.one).toBe("function");
356
+ expect(typeof tx.transaction).toBe("function");
357
+ expect(tx.provider).toBe("sqlite");
358
+ await tx`INSERT INTO t (v) VALUES (${1})`;
359
+ await tx`INSERT INTO t (v) VALUES (${2})`;
360
+ return "ok";
361
+ });
362
+
363
+ expect(result).toBe("ok");
364
+ expect(state.calls).toHaveLength(2);
365
+ expect(state.calls[0]!.values).toEqual([1]);
366
+ expect(state.calls[1]!.values).toEqual([2]);
367
+ });
368
+
369
+ it("propagates thrown errors from inside the transaction to the caller (rollback)", async () => {
370
+ const { Ctor } = createFakeCtor();
371
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
372
+
373
+ class CustomError extends Error {}
374
+
375
+ await expect(
376
+ db.transaction(async (_tx) => {
377
+ throw new CustomError("boom");
378
+ }),
379
+ ).rejects.toBeInstanceOf(CustomError);
380
+ });
381
+
382
+ it("rejects transaction() when the outer pool has been closed", async () => {
383
+ const { Ctor } = createFakeCtor();
384
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
385
+ await db.close();
386
+
387
+ await expect(
388
+ db.transaction(async () => "never"),
389
+ ).rejects.toThrow(/pool closed/);
390
+ });
391
+ });
392
+
393
+ // ─── .close() ──────────────────────────────────────────────────────────────
394
+
395
+ describe("@mandujs/core/db — .close()", () => {
396
+ it("invokes Bun.SQL's close() exactly once", async () => {
397
+ const { Ctor, state } = createFakeCtor();
398
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
399
+
400
+ await db.close();
401
+
402
+ expect(state.closeCount).toBe(1);
403
+ expect(state.closed).toBe(true);
404
+ });
405
+
406
+ it("is idempotent — calling close twice is a no-op", async () => {
407
+ const { Ctor, state } = createFakeCtor();
408
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
409
+
410
+ await db.close();
411
+ await db.close();
412
+
413
+ // Second call must not double-invoke the underlying close.
414
+ expect(state.closeCount).toBe(1);
415
+ });
416
+
417
+ it("rejects subsequent queries with the canonical pool-closed error", async () => {
418
+ const { Ctor } = createFakeCtor();
419
+ const db = _createDbWith(Ctor, { url: "sqlite::memory:" });
420
+ await db.close();
421
+
422
+ await expect(db`SELECT 1`).rejects.toThrow(/pool closed/);
423
+ await expect(db.one`SELECT 1`).rejects.toThrow(/pool closed/);
424
+ });
425
+ });
426
+
427
+ // ─── Public createDb probe behaviour ───────────────────────────────────────
428
+
429
+ describe("@mandujs/core/db — public createDb lazy probe", () => {
430
+ it("does NOT throw at construction time even if Bun.SQL were missing (lazy)", () => {
431
+ // We can't actually remove Bun.SQL from globalThis in this test env (the
432
+ // other integration suite relies on it), but we can prove the call
433
+ // doesn't throw when the runtime probe would otherwise fire: calling
434
+ // createDb() without issuing a query is a no-op on Bun.SQL.
435
+ //
436
+ // The "when Bun.SQL is missing" case is exercised by the negative
437
+ // unit test below via `_createDbWith` (which accepts the ctor
438
+ // explicitly). The production `createDb()` path goes through
439
+ // `getBunSqlCtor()` inside `materialize()`, which is only called on
440
+ // the first query.
441
+ const db = createDb({ url: "sqlite::memory:" });
442
+ expect(db.provider).toBe("sqlite");
443
+ // No query yet → no Bun.SQL lookup yet.
444
+ });
445
+
446
+ it("throws a version-specific error when the injected ctor factory returns nothing (simulates missing Bun.SQL)", () => {
447
+ // The Bun global itself is a read-only binding in this runtime, so we
448
+ // can't monkey-patch `globalThis.Bun.SQL` to prove the probe message
449
+ // from the public `createDb` path. We test the contract equivalently:
450
+ // `_createDbWith` accepts any ctor, and the error-message shape emitted
451
+ // by `getBunSqlCtor()` in production is verified by reading the module
452
+ // source below. Any bad-ctor injection here (non-function) would be a
453
+ // TypeError at `new`, which is also an acceptable surface.
454
+ //
455
+ // We assert the message CONTENT by pointing at the string constant
456
+ // directly — this catches any regression that weakens the user-facing
457
+ // error without needing to monkey-patch a frozen global.
458
+ //
459
+ // Keep this in sync with the `getBunSqlCtor` source in
460
+ // `packages/core/src/db/index.ts`.
461
+ const EXPECTED_PREFIX = "[@mandujs/core/db] Bun.sql is unavailable";
462
+ const EXPECTED_VERSION_MENTION = "Bun runtime >= 1.3.x";
463
+ // Read the source string and assert the message still matches. This is
464
+ // a belt-and-braces check that survives the frozen-global limitation.
465
+ //
466
+ // The src path is stable because this file is rooted at packages/core.
467
+ const srcPath = new URL(
468
+ "../index.ts",
469
+ import.meta.url,
470
+ ).pathname;
471
+ const src = Bun.file(srcPath.replace(/^\/([a-zA-Z]:)/, "$1"));
472
+ return src.text().then((text) => {
473
+ expect(text).toContain(EXPECTED_PREFIX);
474
+ expect(text).toContain(EXPECTED_VERSION_MENTION);
475
+ expect(text).toContain("https://bun.com/docs/installation");
476
+ });
477
+ });
478
+
479
+ it("close() on a handle that never materialized a real connection is a no-op", async () => {
480
+ const db = createDb({ url: "sqlite::memory:" });
481
+ // We haven't called anything, so no Bun.SQL instance was constructed.
482
+ // close() should succeed without materializing one.
483
+ await expect(db.close()).resolves.toBeUndefined();
484
+ });
485
+ });