@mandujs/core 0.21.0 → 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 (122) hide show
  1. package/package.json +94 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -0,0 +1,514 @@
1
+ /**
2
+ * @mandujs/core/scheduler tests
3
+ *
4
+ * These tests inject a controllable fake scheduler via `_defineCronWith`
5
+ * so we never touch `Bun.cron` directly. This keeps the suite fast,
6
+ * deterministic, and independent of Bun runtime version (`Bun.cron` only
7
+ * landed in 1.3.12). The fake captures the tick callback for each job and
8
+ * exposes a `tick()` method the test drives manually.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
12
+ import {
13
+ _defineCronWith,
14
+ defineCron,
15
+ type CronJobConfig,
16
+ type CronScheduleFn,
17
+ } from "../index";
18
+
19
+ /**
20
+ * Minimal scheduler fake: records `(schedule, handler)` pairs, lets the test
21
+ * drive ticks, and returns a stop-able handle so we can assert graceful
22
+ * shutdown behaviour.
23
+ */
24
+ interface FakeSchedule {
25
+ schedule: string;
26
+ handler: () => void | Promise<void>;
27
+ stop: ReturnType<typeof mock>;
28
+ }
29
+
30
+ function makeFakeScheduler(): {
31
+ scheduleFn: CronScheduleFn;
32
+ schedules: FakeSchedule[];
33
+ tick(index: number): Promise<void>;
34
+ } {
35
+ const schedules: FakeSchedule[] = [];
36
+ const scheduleFn: CronScheduleFn = (schedule, handler) => {
37
+ const stop = mock(() => {});
38
+ schedules.push({ schedule, handler, stop });
39
+ return { stop };
40
+ };
41
+ return {
42
+ scheduleFn,
43
+ schedules,
44
+ async tick(index: number): Promise<void> {
45
+ const entry = schedules[index];
46
+ if (!entry) throw new Error(`No schedule at index ${index}`);
47
+ await entry.handler();
48
+ },
49
+ };
50
+ }
51
+
52
+ /** Drain microtasks so inFlight promises settle without advancing timers. */
53
+ async function flushMicrotasks(): Promise<void> {
54
+ await Promise.resolve();
55
+ await Promise.resolve();
56
+ await Promise.resolve();
57
+ }
58
+
59
+ // Preserve NODE_ENV across tests that mutate it.
60
+ let originalNodeEnv: string | undefined;
61
+
62
+ beforeEach(() => {
63
+ originalNodeEnv = process.env.NODE_ENV;
64
+ });
65
+
66
+ afterEach(() => {
67
+ if (originalNodeEnv === undefined) {
68
+ delete process.env.NODE_ENV;
69
+ } else {
70
+ process.env.NODE_ENV = originalNodeEnv;
71
+ }
72
+ });
73
+
74
+ describe("@mandujs/core/scheduler — defineCron (public surface)", () => {
75
+ it("returns a handle with start, stop, and status methods", () => {
76
+ const reg = defineCron({});
77
+ expect(typeof reg.start).toBe("function");
78
+ expect(typeof reg.stop).toBe("function");
79
+ expect(typeof reg.status).toBe("function");
80
+ });
81
+
82
+ it("accepts an empty job set; start and stop are no-ops", async () => {
83
+ const reg = defineCron({});
84
+ // Neither call should touch Bun.cron — if it did, this test would blow up
85
+ // in environments where Bun.cron isn't present (Bun < 1.3.12).
86
+ reg.start();
87
+ await reg.stop();
88
+ expect(reg.status()).toEqual({});
89
+ });
90
+ });
91
+
92
+ describe("@mandujs/core/scheduler — scheduling", () => {
93
+ it("registers each job via the injected scheduleFn with its crontab expression", () => {
94
+ const { scheduleFn, schedules } = makeFakeScheduler();
95
+
96
+ const reg = _defineCronWith(
97
+ {
98
+ "clean:sessions": { schedule: "*/15 * * * *", run: () => {} },
99
+ "daily:report": { schedule: "0 3 * * *", run: () => {} },
100
+ },
101
+ scheduleFn,
102
+ );
103
+
104
+ reg.start();
105
+
106
+ expect(schedules).toHaveLength(2);
107
+ expect(schedules.map((s) => s.schedule).sort()).toEqual([
108
+ "*/15 * * * *",
109
+ "0 3 * * *",
110
+ ]);
111
+ });
112
+
113
+ it("calls the handler once per tick and updates status counters", async () => {
114
+ const { scheduleFn, tick } = makeFakeScheduler();
115
+ const run = mock(async () => {});
116
+
117
+ const reg = _defineCronWith(
118
+ { "j1": { schedule: "* * * * *", run } },
119
+ scheduleFn,
120
+ );
121
+ reg.start();
122
+
123
+ await tick(0);
124
+ expect(run).toHaveBeenCalledTimes(1);
125
+ const s1 = reg.status().j1;
126
+ expect(s1.runCount).toBe(1);
127
+ expect(s1.errorCount).toBe(0);
128
+ expect(s1.skipCount).toBe(0);
129
+ expect(s1.inFlight).toBe(false);
130
+ expect(typeof s1.lastRunAt).toBe("number");
131
+ expect(typeof s1.lastDurationMs).toBe("number");
132
+ expect((s1.lastDurationMs as number) >= 0).toBe(true);
133
+
134
+ await tick(0);
135
+ expect(run).toHaveBeenCalledTimes(2);
136
+ expect(reg.status().j1.runCount).toBe(2);
137
+ });
138
+
139
+ it("passes a CronContext with the job name and scheduledAt Date", async () => {
140
+ const { scheduleFn, tick } = makeFakeScheduler();
141
+ const seen: Array<{ name: string; scheduledAt: Date }> = [];
142
+
143
+ const reg = _defineCronWith(
144
+ {
145
+ "ctx-job": {
146
+ schedule: "* * * * *",
147
+ run: (ctx) => {
148
+ seen.push({ name: ctx.name, scheduledAt: ctx.scheduledAt });
149
+ },
150
+ },
151
+ },
152
+ scheduleFn,
153
+ );
154
+ reg.start();
155
+
156
+ await tick(0);
157
+ expect(seen).toHaveLength(1);
158
+ expect(seen[0].name).toBe("ctx-job");
159
+ expect(seen[0].scheduledAt).toBeInstanceOf(Date);
160
+ // Within a reasonable clock skew (1 minute) of wall time.
161
+ const delta = Math.abs(Date.now() - seen[0].scheduledAt.getTime());
162
+ expect(delta).toBeLessThan(60_000);
163
+ });
164
+ });
165
+
166
+ describe("@mandujs/core/scheduler — error isolation", () => {
167
+ it("increments errorCount when the handler throws and continues on next tick", async () => {
168
+ const { scheduleFn, tick } = makeFakeScheduler();
169
+ // Silence the expected console.error so test output stays clean.
170
+ const originalError = console.error;
171
+ console.error = mock(() => {});
172
+
173
+ try {
174
+ let calls = 0;
175
+ const reg = _defineCronWith(
176
+ {
177
+ "err": {
178
+ schedule: "* * * * *",
179
+ run: async () => {
180
+ calls++;
181
+ if (calls === 1) throw new Error("boom");
182
+ },
183
+ },
184
+ },
185
+ scheduleFn,
186
+ );
187
+ reg.start();
188
+
189
+ await tick(0);
190
+ const afterFirst = reg.status().err;
191
+ expect(afterFirst.errorCount).toBe(1);
192
+ expect(afterFirst.runCount).toBe(1);
193
+ expect(afterFirst.inFlight).toBe(false);
194
+
195
+ await tick(0); // second tick should still run despite prior error
196
+ const afterSecond = reg.status().err;
197
+ expect(afterSecond.errorCount).toBe(1);
198
+ expect(afterSecond.runCount).toBe(2);
199
+
200
+ // Sanity: console.error was invoked with our namespaced prefix.
201
+ const errorMock = console.error as unknown as { mock: { calls: unknown[] } };
202
+ expect(errorMock.mock.calls.length).toBeGreaterThanOrEqual(1);
203
+ } finally {
204
+ console.error = originalError;
205
+ }
206
+ });
207
+ });
208
+
209
+ describe("@mandujs/core/scheduler — overlap prevention", () => {
210
+ it("skips a tick when the previous invocation is still pending", async () => {
211
+ const { scheduleFn, tick } = makeFakeScheduler();
212
+
213
+ // Controllable promise so the first tick's handler hangs until we release it.
214
+ let release!: () => void;
215
+ const hang = new Promise<void>((r) => {
216
+ release = r;
217
+ });
218
+ const run = mock(async () => {
219
+ await hang;
220
+ });
221
+
222
+ const reg = _defineCronWith(
223
+ { "slow": { schedule: "* * * * *", run } },
224
+ scheduleFn,
225
+ );
226
+ reg.start();
227
+
228
+ // Fire tick 1 (hangs).
229
+ const first = tick(0);
230
+ await flushMicrotasks();
231
+ expect(reg.status().slow.inFlight).toBe(true);
232
+ expect(run).toHaveBeenCalledTimes(1);
233
+
234
+ // Fire tick 2 while tick 1 is still pending — must skip.
235
+ await tick(0);
236
+ expect(run).toHaveBeenCalledTimes(1);
237
+ expect(reg.status().slow.skipCount).toBe(1);
238
+
239
+ // Fire tick 3 — also skips.
240
+ await tick(0);
241
+ expect(run).toHaveBeenCalledTimes(1);
242
+ expect(reg.status().slow.skipCount).toBe(2);
243
+
244
+ // Release the hung handler; subsequent ticks should run again.
245
+ release();
246
+ await first;
247
+
248
+ await tick(0);
249
+ expect(run).toHaveBeenCalledTimes(2);
250
+ expect(reg.status().slow.skipCount).toBe(2); // unchanged
251
+ expect(reg.status().slow.runCount).toBe(2);
252
+ });
253
+ });
254
+
255
+ describe("@mandujs/core/scheduler — dev-mode skip", () => {
256
+ it("does not schedule jobs with skipInDev=true when NODE_ENV !== 'production'", () => {
257
+ process.env.NODE_ENV = "development";
258
+ const { scheduleFn, schedules } = makeFakeScheduler();
259
+
260
+ const reg = _defineCronWith(
261
+ {
262
+ "prod-only": { schedule: "* * * * *", run: () => {}, skipInDev: true },
263
+ "always": { schedule: "* * * * *", run: () => {} },
264
+ },
265
+ scheduleFn,
266
+ );
267
+ reg.start();
268
+
269
+ // Only the non-skipped job registered.
270
+ expect(schedules).toHaveLength(1);
271
+
272
+ // Status still reports the skipped job with zero counters so observability
273
+ // dashboards don't have to special-case its absence.
274
+ const status = reg.status();
275
+ expect(status["prod-only"]).toBeDefined();
276
+ expect(status["prod-only"].runCount).toBe(0);
277
+ expect(status["always"]).toBeDefined();
278
+ });
279
+
280
+ it("DOES schedule skipInDev jobs when NODE_ENV === 'production'", async () => {
281
+ process.env.NODE_ENV = "production";
282
+ const { scheduleFn, schedules, tick } = makeFakeScheduler();
283
+ const run = mock(() => {});
284
+
285
+ const reg = _defineCronWith(
286
+ { "prod-only": { schedule: "* * * * *", run, skipInDev: true } },
287
+ scheduleFn,
288
+ );
289
+ reg.start();
290
+
291
+ expect(schedules).toHaveLength(1);
292
+ await tick(0);
293
+ expect(run).toHaveBeenCalledTimes(1);
294
+ expect(reg.status()["prod-only"].runCount).toBe(1);
295
+ });
296
+ });
297
+
298
+ describe("@mandujs/core/scheduler — timeout", () => {
299
+ it("marks the tick as completed after timeoutMs and clears inFlight so the next tick can fire", async () => {
300
+ const { scheduleFn, tick } = makeFakeScheduler();
301
+ // Silence the expected console.warn.
302
+ const originalWarn = console.warn;
303
+ console.warn = mock(() => {});
304
+
305
+ try {
306
+ // A handler that never resolves on its own — only the timeout will
307
+ // release the scheduler from "in-flight".
308
+ const run = mock(() => new Promise<void>(() => {}));
309
+
310
+ const reg = _defineCronWith(
311
+ {
312
+ "slow": {
313
+ schedule: "* * * * *",
314
+ run,
315
+ timeoutMs: 10,
316
+ },
317
+ },
318
+ scheduleFn,
319
+ );
320
+ reg.start();
321
+
322
+ // First tick hangs, then times out after 10ms.
323
+ await tick(0);
324
+ expect(run).toHaveBeenCalledTimes(1);
325
+
326
+ const afterTimeout = reg.status().slow;
327
+ expect(afterTimeout.inFlight).toBe(false);
328
+ expect(afterTimeout.runCount).toBe(1);
329
+
330
+ // Second tick can now fire since inFlight is cleared.
331
+ await tick(0);
332
+ expect(run).toHaveBeenCalledTimes(2);
333
+ expect(reg.status().slow.runCount).toBe(2);
334
+
335
+ const warnMock = console.warn as unknown as { mock: { calls: unknown[] } };
336
+ expect(warnMock.mock.calls.length).toBeGreaterThanOrEqual(1);
337
+ } finally {
338
+ console.warn = originalWarn;
339
+ }
340
+ });
341
+ });
342
+
343
+ describe("@mandujs/core/scheduler — shutdown", () => {
344
+ it("stop() waits for in-flight handlers to complete before resolving", async () => {
345
+ const { scheduleFn, tick } = makeFakeScheduler();
346
+
347
+ let release!: () => void;
348
+ const hang = new Promise<void>((r) => {
349
+ release = r;
350
+ });
351
+ let handlerFinished = false;
352
+
353
+ const reg = _defineCronWith(
354
+ {
355
+ "slow": {
356
+ schedule: "* * * * *",
357
+ run: async () => {
358
+ await hang;
359
+ handlerFinished = true;
360
+ },
361
+ },
362
+ },
363
+ scheduleFn,
364
+ );
365
+ reg.start();
366
+
367
+ // Kick off a tick and wait for it to be observed in-flight.
368
+ const tickPromise = tick(0);
369
+ await flushMicrotasks();
370
+ expect(reg.status().slow.inFlight).toBe(true);
371
+
372
+ // stop() should be pending until the handler finishes.
373
+ let stopResolved = false;
374
+ const stopPromise = reg.stop().then(() => {
375
+ stopResolved = true;
376
+ });
377
+
378
+ await flushMicrotasks();
379
+ expect(stopResolved).toBe(false);
380
+ expect(handlerFinished).toBe(false);
381
+
382
+ // Release the handler; stop() should now resolve.
383
+ release();
384
+ await tickPromise;
385
+ await stopPromise;
386
+ expect(handlerFinished).toBe(true);
387
+ expect(stopResolved).toBe(true);
388
+ });
389
+
390
+ it("stop() invokes each underlying schedule's stop() so no new ticks fire", async () => {
391
+ const { scheduleFn, schedules } = makeFakeScheduler();
392
+
393
+ const reg = _defineCronWith(
394
+ {
395
+ "a": { schedule: "* * * * *", run: () => {} },
396
+ "b": { schedule: "* * * * *", run: () => {} },
397
+ },
398
+ scheduleFn,
399
+ );
400
+ reg.start();
401
+ await reg.stop();
402
+
403
+ expect(schedules[0].stop).toHaveBeenCalledTimes(1);
404
+ expect(schedules[1].stop).toHaveBeenCalledTimes(1);
405
+ });
406
+
407
+ it("start() then stop() then start() is a clean restart; counters are preserved", async () => {
408
+ const { scheduleFn, schedules, tick } = makeFakeScheduler();
409
+ const run = mock(() => {});
410
+
411
+ const reg = _defineCronWith(
412
+ { "j": { schedule: "* * * * *", run } },
413
+ scheduleFn,
414
+ );
415
+
416
+ reg.start();
417
+ await tick(0);
418
+ expect(reg.status().j.runCount).toBe(1);
419
+
420
+ await reg.stop();
421
+ expect(reg.status().j.runCount).toBe(1); // preserved
422
+
423
+ // Second start() re-registers with the fake.
424
+ reg.start();
425
+ expect(schedules).toHaveLength(2);
426
+
427
+ // New handler was registered; drive it via its new index.
428
+ await tick(1);
429
+ expect(reg.status().j.runCount).toBe(2);
430
+ });
431
+
432
+ it("start() is idempotent — calling twice does not double-register", () => {
433
+ const { scheduleFn, schedules } = makeFakeScheduler();
434
+
435
+ const reg = _defineCronWith(
436
+ { "j": { schedule: "* * * * *", run: () => {} } },
437
+ scheduleFn,
438
+ );
439
+
440
+ reg.start();
441
+ reg.start();
442
+ expect(schedules).toHaveLength(1);
443
+ });
444
+ });
445
+
446
+ describe("@mandujs/core/scheduler — status shape", () => {
447
+ it("returns a fully populated CronJobStatus for every registered job", () => {
448
+ const { scheduleFn } = makeFakeScheduler();
449
+ const jobs: Record<string, CronJobConfig> = {
450
+ "a": { schedule: "* * * * *", run: () => {} },
451
+ "b": { schedule: "* * * * *", run: () => {}, skipInDev: true },
452
+ };
453
+ process.env.NODE_ENV = "development";
454
+
455
+ const reg = _defineCronWith(jobs, scheduleFn);
456
+ reg.start();
457
+
458
+ const status = reg.status();
459
+ expect(Object.keys(status).sort()).toEqual(["a", "b"]);
460
+
461
+ for (const s of Object.values(status)) {
462
+ expect(s).toHaveProperty("lastRunAt");
463
+ expect(s).toHaveProperty("lastDurationMs");
464
+ expect(s).toHaveProperty("inFlight");
465
+ expect(s).toHaveProperty("runCount");
466
+ expect(s).toHaveProperty("skipCount");
467
+ expect(s).toHaveProperty("errorCount");
468
+ expect(s.lastRunAt).toBeNull();
469
+ expect(s.lastDurationMs).toBeNull();
470
+ expect(s.inFlight).toBe(false);
471
+ expect(s.runCount).toBe(0);
472
+ expect(s.skipCount).toBe(0);
473
+ expect(s.errorCount).toBe(0);
474
+ }
475
+ });
476
+
477
+ it("returns a shallow-cloned snapshot so callers cannot mutate internal state", async () => {
478
+ const { scheduleFn, tick } = makeFakeScheduler();
479
+ const reg = _defineCronWith(
480
+ { "j": { schedule: "* * * * *", run: () => {} } },
481
+ scheduleFn,
482
+ );
483
+ reg.start();
484
+ await tick(0);
485
+
486
+ const snap = reg.status();
487
+ snap.j.runCount = 999;
488
+ expect(reg.status().j.runCount).toBe(1);
489
+ });
490
+ });
491
+
492
+ describe("@mandujs/core/scheduler — public defineCron probe", () => {
493
+ it("throws a clear error when Bun.cron is unavailable and start() is invoked", () => {
494
+ // Bun 1.3.10 (the test environment here) does not expose Bun.cron. The
495
+ // module should surface a readable error that names the required version.
496
+ const hasBunCron =
497
+ typeof (globalThis as { Bun?: { cron?: unknown } }).Bun?.cron === "function";
498
+
499
+ const reg = defineCron({
500
+ "noop": { schedule: "* * * * *", run: () => {} },
501
+ });
502
+
503
+ if (hasBunCron) {
504
+ // Bun.cron exists — defineCron should succeed. We can't reliably call
505
+ // real .stop() here without waiting for real cron, so just verify start
506
+ // doesn't throw.
507
+ expect(() => reg.start()).not.toThrow();
508
+ // Best effort cleanup.
509
+ void reg.stop();
510
+ } else {
511
+ expect(() => reg.start()).toThrow(/Bun\.cron is unavailable/);
512
+ }
513
+ });
514
+ });