@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,343 @@
1
+ /**
2
+ * @mandujs/core/scheduler
3
+ *
4
+ * Thin, production-minded wrapper around `Bun.cron` (Bun 1.3.12+). Adds four
5
+ * things the native API doesn't give on its own:
6
+ *
7
+ * 1. **Overlap prevention** — a second tick that fires while the previous
8
+ * handler is still pending increments `skipCount` instead of running the
9
+ * body concurrently. Native `Bun.cron` documents the same guarantee but
10
+ * we also enforce it defensively and surface the skip count.
11
+ * 2. **Per-tick timeout (soft)** — `timeoutMs` logs a warning and clears the
12
+ * in-flight flag so the *next* scheduled tick can run; it does NOT abort
13
+ * the current handler (Bun.cron has no cancellation primitive). The
14
+ * original handler keeps running; we just stop blocking future ticks on
15
+ * it. This is the best you can do against a hung job without killing the
16
+ * process.
17
+ * 3. **Dev-mode skip** — jobs marked `skipInDev: true` are not registered
18
+ * when `NODE_ENV !== "production"`. They still appear in `status()` with
19
+ * zero counters so dashboards don't have to special-case them.
20
+ * 4. **Graceful shutdown** — `stop()` prevents new ticks immediately and
21
+ * resolves once all in-flight handlers settle.
22
+ *
23
+ * Single-process assumption: there is NO distributed lock, NO persistent queue,
24
+ * and NO cross-instance coordination. Running two processes with the same
25
+ * `defineCron` config will fire each job on every process. Use a queue
26
+ * (BullMQ, PG-boss, SQS) if you need exactly-once or multi-instance semantics.
27
+ *
28
+ * At-most-once on restart: if the process dies between ticks, the missed tick
29
+ * is lost — `Bun.cron` computes "next fire" from the moment it starts, not
30
+ * from a persisted schedule. Document this for any job whose absence matters.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * import { defineCron } from "@mandujs/core/scheduler";
35
+ *
36
+ * const jobs = defineCron({
37
+ * "clean:sessions": {
38
+ * schedule: "*\/15 * * * *",
39
+ * run: async () => { await db.exec("DELETE FROM sessions WHERE expires_at < now()"); },
40
+ * skipInDev: true,
41
+ * },
42
+ * "daily:report": {
43
+ * schedule: "0 3 * * *",
44
+ * run: async ({ scheduledAt }) => { await emailReport(scheduledAt); },
45
+ * timeoutMs: 5 * 60_000,
46
+ * },
47
+ * });
48
+ *
49
+ * jobs.start();
50
+ * // ...later, on shutdown:
51
+ * await jobs.stop();
52
+ * ```
53
+ *
54
+ * @module scheduler
55
+ */
56
+
57
+ /** Context passed to each job handler. */
58
+ export interface CronContext {
59
+ /** Job name (the key under which the job was registered). */
60
+ name: string;
61
+ /** The scheduled firing time (close to, but not exactly, now). */
62
+ scheduledAt: Date;
63
+ }
64
+
65
+ /** Configuration for a single cron job. */
66
+ export interface CronJobConfig {
67
+ /** Crontab expression. Examples: "*\/15 * * * *", "0 3 * * *", "@daily". */
68
+ schedule: string;
69
+ /** Job handler. May be async; return value is ignored. */
70
+ run: (ctx: CronContext) => void | Promise<void>;
71
+ /** Skip registration in dev mode (NODE_ENV !== "production"). Default: false. */
72
+ skipInDev?: boolean;
73
+ /**
74
+ * Soft timeout in ms. On timeout, a warning is logged and the in-flight flag
75
+ * clears so the next tick can run. The current handler is NOT aborted —
76
+ * Bun.cron has no cancellation primitive. Default: unlimited.
77
+ */
78
+ timeoutMs?: number;
79
+ }
80
+
81
+ /** Observable status for a single job. */
82
+ export interface CronJobStatus {
83
+ /** Epoch ms of the last completed run, or null if never run. */
84
+ lastRunAt: number | null;
85
+ /** Duration of the last completed run in ms, or null if never run. */
86
+ lastDurationMs: number | null;
87
+ /** True while a handler is executing. */
88
+ inFlight: boolean;
89
+ /** Number of handler invocations that reached completion (including errors). */
90
+ runCount: number;
91
+ /** Number of ticks dropped because the previous run had not finished. */
92
+ skipCount: number;
93
+ /** Number of handler invocations that threw. */
94
+ errorCount: number;
95
+ }
96
+
97
+ /** Handle returned by {@link defineCron}. */
98
+ export interface CronRegistration {
99
+ /** Schedule all non-dev-skipped jobs. Idempotent — calling twice is a no-op. */
100
+ start(): void;
101
+ /** Stop accepting new ticks and wait for any in-flight handler to finish. */
102
+ stop(): Promise<void>;
103
+ /** Snapshot per-job statistics. */
104
+ status(): Record<string, CronJobStatus>;
105
+ }
106
+
107
+ /** Minimal shape of the thing `Bun.cron` returns. */
108
+ interface CronJobHandle {
109
+ stop?: () => void | Promise<void>;
110
+ }
111
+
112
+ /**
113
+ * Function shape used to register a cron schedule. Matches `Bun.cron` but kept
114
+ * abstract so tests can inject a controllable fake.
115
+ *
116
+ * @internal
117
+ */
118
+ export type CronScheduleFn = (
119
+ schedule: string,
120
+ handler: () => void | Promise<void>,
121
+ ) => CronJobHandle | void;
122
+
123
+ interface BunCronGlobal {
124
+ cron?: CronScheduleFn;
125
+ }
126
+
127
+ /**
128
+ * Resolves `Bun.cron` at call time. Throws a clear, actionable error when the
129
+ * runtime doesn't provide it — matches the `auth/password.ts` style.
130
+ */
131
+ function getBunCron(): CronScheduleFn {
132
+ const g = globalThis as unknown as { Bun?: BunCronGlobal };
133
+ if (!g.Bun || typeof g.Bun.cron !== "function") {
134
+ throw new Error(
135
+ "[@mandujs/core/scheduler] Bun.cron is unavailable — this module requires the Bun runtime (>= 1.3.12).",
136
+ );
137
+ }
138
+ return g.Bun.cron;
139
+ }
140
+
141
+ /** Per-job mutable runtime state. */
142
+ interface JobState {
143
+ readonly name: string;
144
+ readonly config: CronJobConfig;
145
+ readonly skipped: boolean;
146
+ handle: CronJobHandle | null;
147
+ status: CronJobStatus;
148
+ /** Resolves when the in-flight handler (if any) finishes. */
149
+ inFlightSettle: Promise<void> | null;
150
+ }
151
+
152
+ /**
153
+ * Registers a set of cron jobs. Returns a handle; does NOT auto-start —
154
+ * call `.start()` from your server boot sequence.
155
+ *
156
+ * The public API. Internally dispatches to {@link _defineCronWith} passing
157
+ * `Bun.cron` as the scheduler.
158
+ */
159
+ export function defineCron(jobs: Record<string, CronJobConfig>): CronRegistration {
160
+ // Probe lazily so `defineCron({})` with no entries can still be called in
161
+ // environments without `Bun.cron`. When the user actually goes to `start()`,
162
+ // the probe runs — matching `getBunPassword()` behaviour.
163
+ return _defineCronWith(jobs, (schedule, handler) => getBunCron()(schedule, handler));
164
+ }
165
+
166
+ /**
167
+ * Core constructor. Exposed for tests so they can inject a controllable fake
168
+ * scheduler and drive ticks deterministically without touching real cron.
169
+ *
170
+ * @internal
171
+ */
172
+ export function _defineCronWith(
173
+ jobs: Record<string, CronJobConfig>,
174
+ scheduleFn: CronScheduleFn,
175
+ ): CronRegistration {
176
+ const isProd =
177
+ typeof process !== "undefined" && process.env?.NODE_ENV === "production";
178
+
179
+ // Freeze the job set at definition time — no add/remove after construction.
180
+ const names = Object.keys(jobs);
181
+ const states: Map<string, JobState> = new Map();
182
+ for (const name of names) {
183
+ const config = jobs[name];
184
+ const skipped = config.skipInDev === true && !isProd;
185
+ states.set(name, {
186
+ name,
187
+ config,
188
+ skipped,
189
+ handle: null,
190
+ status: {
191
+ lastRunAt: null,
192
+ lastDurationMs: null,
193
+ inFlight: false,
194
+ runCount: 0,
195
+ skipCount: 0,
196
+ errorCount: 0,
197
+ },
198
+ inFlightSettle: null,
199
+ });
200
+ }
201
+
202
+ let started = false;
203
+ let stopping = false;
204
+
205
+ function makeTickHandler(state: JobState): () => Promise<void> {
206
+ return async () => {
207
+ // No new ticks once we've started stopping.
208
+ if (stopping) return;
209
+
210
+ // Overlap prevention: if the previous invocation is still running, skip.
211
+ if (state.status.inFlight) {
212
+ state.status.skipCount += 1;
213
+ return;
214
+ }
215
+
216
+ state.status.inFlight = true;
217
+ const startedAt = Date.now();
218
+
219
+ const ctx: CronContext = {
220
+ name: state.name,
221
+ scheduledAt: new Date(startedAt),
222
+ };
223
+
224
+ // The promise that future ticks (and `stop()`) wait on. We capture it
225
+ // in a variable so the `.finally()` can resolve the outer promise even
226
+ // if `run()` itself throws synchronously.
227
+ let settleResolve!: () => void;
228
+ const settle = new Promise<void>((r) => {
229
+ settleResolve = r;
230
+ });
231
+ state.inFlightSettle = settle;
232
+
233
+ const runAndCount = (async () => {
234
+ try {
235
+ await state.config.run(ctx);
236
+ } catch (error) {
237
+ state.status.errorCount += 1;
238
+ // Error isolation — never let a handler crash the process.
239
+ console.error(
240
+ `[scheduler] job ${state.name} failed:`,
241
+ error,
242
+ );
243
+ }
244
+ })();
245
+
246
+ // Decide whether to wait for the handler or give up after timeout.
247
+ const timeoutMs = state.config.timeoutMs;
248
+ if (typeof timeoutMs === "number" && timeoutMs > 0) {
249
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
250
+ const timeoutMarker = Symbol("timeout");
251
+ const timeoutPromise = new Promise<typeof timeoutMarker>((resolve) => {
252
+ timeoutHandle = setTimeout(() => resolve(timeoutMarker), timeoutMs);
253
+ });
254
+
255
+ const winner = await Promise.race([runAndCount.then(() => null), timeoutPromise]);
256
+
257
+ if (winner === timeoutMarker) {
258
+ // Handler is still running on its own. Log, clear inFlight so the
259
+ // next tick can fire, but do NOT attempt to cancel — Bun.cron has
260
+ // no cancellation and calling back into the handler would risk
261
+ // double-execution.
262
+ console.warn(
263
+ `[scheduler] job ${state.name} exceeded timeoutMs=${timeoutMs} — future ticks may run while the previous handler is still executing.`,
264
+ );
265
+ state.status.runCount += 1;
266
+ state.status.lastRunAt = Date.now();
267
+ state.status.lastDurationMs = Date.now() - startedAt;
268
+ state.status.inFlight = false;
269
+ settleResolve();
270
+ state.inFlightSettle = null;
271
+ return;
272
+ }
273
+
274
+ // Handler finished first — clear the timeout to avoid a leaked timer.
275
+ if (timeoutHandle !== undefined) {
276
+ clearTimeout(timeoutHandle);
277
+ }
278
+ } else {
279
+ await runAndCount;
280
+ }
281
+
282
+ state.status.runCount += 1;
283
+ state.status.lastRunAt = Date.now();
284
+ state.status.lastDurationMs = Date.now() - startedAt;
285
+ state.status.inFlight = false;
286
+ settleResolve();
287
+ state.inFlightSettle = null;
288
+ };
289
+ }
290
+
291
+ function start(): void {
292
+ if (started) return;
293
+ started = true;
294
+ stopping = false;
295
+ for (const state of states.values()) {
296
+ if (state.skipped) continue;
297
+ const tick = makeTickHandler(state);
298
+ const handle = scheduleFn(state.config.schedule, tick);
299
+ state.handle = handle ?? null;
300
+ }
301
+ }
302
+
303
+ async function stop(): Promise<void> {
304
+ if (!started) return;
305
+ stopping = true;
306
+ // Tell each underlying cron to stop firing new ticks. Handles returned
307
+ // from `Bun.cron` may be void (docs show `await Bun.cron.remove(name)` as
308
+ // the alternate shape), so we defensively handle both.
309
+ const stopPromises: Array<Promise<void>> = [];
310
+ for (const state of states.values()) {
311
+ if (state.handle && typeof state.handle.stop === "function") {
312
+ const r = state.handle.stop();
313
+ if (r && typeof (r as Promise<void>).then === "function") {
314
+ stopPromises.push(r as Promise<void>);
315
+ }
316
+ }
317
+ state.handle = null;
318
+ }
319
+ if (stopPromises.length > 0) {
320
+ await Promise.allSettled(stopPromises);
321
+ }
322
+ // Wait for any in-flight handler to settle.
323
+ const inflight: Array<Promise<void>> = [];
324
+ for (const state of states.values()) {
325
+ if (state.inFlightSettle) inflight.push(state.inFlightSettle);
326
+ }
327
+ if (inflight.length > 0) {
328
+ await Promise.allSettled(inflight);
329
+ }
330
+ started = false;
331
+ }
332
+
333
+ function status(): Record<string, CronJobStatus> {
334
+ const out: Record<string, CronJobStatus> = {};
335
+ for (const [name, state] of states) {
336
+ // Snapshot (shallow clone) so callers can't mutate internal state.
337
+ out[name] = { ...state.status };
338
+ }
339
+ return out;
340
+ }
341
+
342
+ return { start, stop, status };
343
+ }