@mandujs/core 0.29.1 → 0.31.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.
@@ -1,343 +1,547 @@
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
- }
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
+ import { validateCronExpression, validateTimezone } from "./validate";
58
+ export { validateCronExpression, validateTimezone } from "./validate";
59
+
60
+ /** Context passed to each job handler. */
61
+ export interface CronContext {
62
+ /** Job name (the key under which the job was registered). */
63
+ name: string;
64
+ /** The scheduled firing time (close to, but not exactly, now). */
65
+ scheduledAt: Date;
66
+ /**
67
+ * Lightweight namespaced logger. Avoids forcing consumers to import the full
68
+ * `@mandujs/core/logging` surface from inside a cron handler. Writes through
69
+ * to `console.*` with a `[scheduler:<name>]` prefix.
70
+ */
71
+ log: {
72
+ info: (...args: unknown[]) => void;
73
+ warn: (...args: unknown[]) => void;
74
+ error: (...args: unknown[]) => void;
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Where a job may execute. Consumers declare `runOn` on each job so a single
80
+ * config can drive BOTH the local Bun runtime AND Cloudflare Workers Cron
81
+ * Triggers:
82
+ *
83
+ * - `"bun"` — register with `Bun.cron()` at server boot.
84
+ * - `"workers"` — emit into `wrangler.toml` `[triggers] crons = [...]` at
85
+ * build time; `createWorkersHandler` dispatches to the
86
+ * handler on `scheduled(event)` invocation.
87
+ *
88
+ * Omitting the field defaults to `["bun", "workers"]` so a single job runs
89
+ * everywhere it can without ceremony.
90
+ */
91
+ export type CronRuntime = "bun" | "workers";
92
+
93
+ /** Configuration for a single cron job. */
94
+ export interface CronJobConfig {
95
+ /** Crontab expression. Examples: "*\/15 * * * *", "0 3 * * *", "@daily". */
96
+ schedule: string;
97
+ /** Job handler. May be async; return value is ignored. */
98
+ run: (ctx: CronContext) => void | Promise<void>;
99
+ /** Skip registration in dev mode (NODE_ENV !== "production"). Default: false. */
100
+ skipInDev?: boolean;
101
+ /**
102
+ * Soft timeout in ms. On timeout, a warning is logged and the in-flight flag
103
+ * clears so the next tick can run. The current handler is NOT aborted —
104
+ * Bun.cron has no cancellation primitive. Default: unlimited.
105
+ */
106
+ timeoutMs?: number;
107
+ /**
108
+ * IANA timezone for the schedule (e.g., `"UTC"`, `"America/New_York"`).
109
+ * Passed through to `Bun.cron` which interprets the crontab expression
110
+ * against this zone. **Not supported on Cloudflare Workers** — Workers cron
111
+ * triggers always fire in UTC; emit a warning but still emit the crontab.
112
+ *
113
+ * Default: host system timezone (Bun.cron's default).
114
+ */
115
+ timezone?: string;
116
+ /**
117
+ * Runtimes on which this job should execute. When the job is instantiated
118
+ * via `_defineCronWith` on a Bun host, only entries including `"bun"` are
119
+ * registered with `Bun.cron`. The CLI emits wrangler triggers only for
120
+ * entries including `"workers"`. Default: `["bun", "workers"]`.
121
+ */
122
+ runOn?: CronRuntime[];
123
+ }
124
+
125
+ /**
126
+ * Declarative cron job definition (array form). Mirrors `CronJobConfig` but
127
+ * lifts `name` inside the object so a single flat array can be passed:
128
+ *
129
+ * ```ts
130
+ * export const cleanupJob = defineCron({
131
+ * name: 'cleanup-expired-sessions',
132
+ * schedule: '0 * * * *',
133
+ * timezone: 'UTC',
134
+ * runOn: ['bun', 'workers'],
135
+ * handler: async (ctx) => { ... },
136
+ * });
137
+ * ```
138
+ *
139
+ * `handler` is the canonical field name (Cloudflare convention); `run` is
140
+ * accepted as an alias to match the existing object-form API.
141
+ */
142
+ export interface CronDef {
143
+ /** Unique job name. Used for logs, status(), and as the Map key internally. */
144
+ name: string;
145
+ /** Crontab expression or `@alias`. */
146
+ schedule: string;
147
+ /** Handler invoked on every tick. Canonical field. */
148
+ handler?: (ctx: CronContext) => void | Promise<void>;
149
+ /** Alias for `handler` — matches the object-form `CronJobConfig.run`. */
150
+ run?: (ctx: CronContext) => void | Promise<void>;
151
+ /** See {@link CronJobConfig.skipInDev}. */
152
+ skipInDev?: boolean;
153
+ /** See {@link CronJobConfig.timeoutMs}. */
154
+ timeoutMs?: number;
155
+ /** See {@link CronJobConfig.timezone}. */
156
+ timezone?: string;
157
+ /** See {@link CronJobConfig.runOn}. */
158
+ runOn?: CronRuntime[];
159
+ }
160
+
161
+ /** Observable status for a single job. */
162
+ export interface CronJobStatus {
163
+ /** Epoch ms of the last completed run, or null if never run. */
164
+ lastRunAt: number | null;
165
+ /** Duration of the last completed run in ms, or null if never run. */
166
+ lastDurationMs: number | null;
167
+ /** True while a handler is executing. */
168
+ inFlight: boolean;
169
+ /** Number of handler invocations that reached completion (including errors). */
170
+ runCount: number;
171
+ /** Number of ticks dropped because the previous run had not finished. */
172
+ skipCount: number;
173
+ /** Number of handler invocations that threw. */
174
+ errorCount: number;
175
+ }
176
+
177
+ /** Handle returned by {@link defineCron}. */
178
+ export interface CronRegistration {
179
+ /** Schedule all non-dev-skipped jobs. Idempotent calling twice is a no-op. */
180
+ start(): void;
181
+ /** Stop accepting new ticks and wait for any in-flight handler to finish. */
182
+ stop(): Promise<void>;
183
+ /** Snapshot per-job statistics. */
184
+ status(): Record<string, CronJobStatus>;
185
+ }
186
+
187
+ /** Minimal shape of the thing `Bun.cron` returns. */
188
+ interface CronJobHandle {
189
+ stop?: () => void | Promise<void>;
190
+ }
191
+
192
+ /**
193
+ * Function shape used to register a cron schedule. Matches `Bun.cron` but kept
194
+ * abstract so tests can inject a controllable fake.
195
+ *
196
+ * @internal
197
+ */
198
+ export type CronScheduleFn = (
199
+ schedule: string,
200
+ handler: () => void | Promise<void>,
201
+ ) => CronJobHandle | void;
202
+
203
+ interface BunCronGlobal {
204
+ cron?: CronScheduleFn;
205
+ }
206
+
207
+ /**
208
+ * Resolves `Bun.cron` at call time. Throws a clear, actionable error when the
209
+ * runtime doesn't provide it — matches the `auth/password.ts` style.
210
+ */
211
+ function getBunCron(): CronScheduleFn {
212
+ const g = globalThis as unknown as { Bun?: BunCronGlobal };
213
+ if (!g.Bun || typeof g.Bun.cron !== "function") {
214
+ throw new Error(
215
+ "[@mandujs/core/scheduler] Bun.cron is unavailable — this module requires the Bun runtime (>= 1.3.12).",
216
+ );
217
+ }
218
+ return g.Bun.cron;
219
+ }
220
+
221
+ /** Per-job mutable runtime state. */
222
+ interface JobState {
223
+ readonly name: string;
224
+ readonly config: CronJobConfig;
225
+ readonly skipped: boolean;
226
+ handle: CronJobHandle | null;
227
+ status: CronJobStatus;
228
+ /** Resolves when the in-flight handler (if any) finishes. */
229
+ inFlightSettle: Promise<void> | null;
230
+ }
231
+
232
+ /**
233
+ * Public `defineCron` registers one or more cron jobs. Accepts two shapes:
234
+ *
235
+ * 1. Object-form: `defineCron({ name1: CronJobConfig, name2: CronJobConfig })`
236
+ * — the original API, preserved for backwards compatibility.
237
+ *
238
+ * 2. Array / single-entry form: `defineCron(CronDef | CronDef[])` the
239
+ * flat-object shape documented in the Phase 18.λ spec. `name` is
240
+ * embedded in the object and `handler` is the canonical handler field
241
+ * (aliased as `run` for symmetry).
242
+ *
243
+ * Returns a `CronRegistration` handle. Does NOT auto-start — call `.start()`
244
+ * from your server boot sequence (or let `startServer()` do it for you when
245
+ * `scheduler.jobs` is set in `mandu.config.ts`).
246
+ *
247
+ * Schedule strings are validated synchronously via {@link validateCronExpression}
248
+ * so malformed cron expressions fail fast at module-load time instead of
249
+ * producing a silent "never fires" at runtime.
250
+ */
251
+ export function defineCron(
252
+ input: Record<string, CronJobConfig> | CronDef | CronDef[],
253
+ ): CronRegistration {
254
+ const jobs = normalizeDefineCronInput(input);
255
+ // Probe lazily so `defineCron({})` with no entries can still be called in
256
+ // environments without `Bun.cron`. When the user actually goes to `start()`,
257
+ // the probe runs — matching `getBunPassword()` behaviour.
258
+ return _defineCronWith(jobs, (schedule, handler) => getBunCron()(schedule, handler));
259
+ }
260
+
261
+ /**
262
+ * Normalize the public `defineCron` input into the internal
263
+ * `Record<string, CronJobConfig>` shape. Validates schedule + timezone fields
264
+ * at the boundary so downstream code can assume they're well-formed.
265
+ *
266
+ * @internal — exported for test coverage only.
267
+ */
268
+ export function normalizeDefineCronInput(
269
+ input: Record<string, CronJobConfig> | CronDef | CronDef[],
270
+ ): Record<string, CronJobConfig> {
271
+ const out: Record<string, CronJobConfig> = {};
272
+
273
+ const defs: CronDef[] = Array.isArray(input)
274
+ ? input
275
+ : isCronDef(input)
276
+ ? [input as CronDef]
277
+ : []; // fall through to the object-form branch below.
278
+
279
+ if (defs.length > 0) {
280
+ for (const def of defs) {
281
+ if (typeof def.name !== "string" || def.name.length === 0) {
282
+ throw new Error(
283
+ `[@mandujs/core/scheduler] defineCron: every CronDef must have a non-empty "name" field.`,
284
+ );
285
+ }
286
+ if (out[def.name] !== undefined) {
287
+ throw new Error(
288
+ `[@mandujs/core/scheduler] defineCron: duplicate job name "${def.name}".`,
289
+ );
290
+ }
291
+ validateCronExpression(def.schedule);
292
+ if (def.timezone !== undefined) validateTimezone(def.timezone);
293
+ const handler = def.handler ?? def.run;
294
+ if (typeof handler !== "function") {
295
+ throw new Error(
296
+ `[@mandujs/core/scheduler] defineCron: job "${def.name}" must define a "handler" (or "run") function.`,
297
+ );
298
+ }
299
+ out[def.name] = {
300
+ schedule: def.schedule,
301
+ run: handler,
302
+ skipInDev: def.skipInDev,
303
+ timeoutMs: def.timeoutMs,
304
+ timezone: def.timezone,
305
+ runOn: def.runOn,
306
+ };
307
+ }
308
+ return out;
309
+ }
310
+
311
+ // Object-form branch.
312
+ if (input && typeof input === "object" && !Array.isArray(input)) {
313
+ for (const [name, cfg] of Object.entries(input as Record<string, CronJobConfig>)) {
314
+ if (!cfg || typeof cfg !== "object") {
315
+ throw new Error(
316
+ `[@mandujs/core/scheduler] defineCron: job "${name}" config must be an object.`,
317
+ );
318
+ }
319
+ validateCronExpression(cfg.schedule);
320
+ if (cfg.timezone !== undefined) validateTimezone(cfg.timezone);
321
+ if (typeof cfg.run !== "function") {
322
+ throw new Error(
323
+ `[@mandujs/core/scheduler] defineCron: job "${name}" must define a "run" function.`,
324
+ );
325
+ }
326
+ out[name] = cfg;
327
+ }
328
+ }
329
+
330
+ return out;
331
+ }
332
+
333
+ function isCronDef(v: unknown): v is CronDef {
334
+ return (
335
+ typeof v === "object" &&
336
+ v !== null &&
337
+ "name" in (v as Record<string, unknown>) &&
338
+ "schedule" in (v as Record<string, unknown>) &&
339
+ (typeof (v as CronDef).handler === "function" ||
340
+ typeof (v as CronDef).run === "function")
341
+ );
342
+ }
343
+
344
+ /**
345
+ * Return the list of jobs slated to run on a given runtime. Default `runOn`
346
+ * for any job that omits the field is `["bun", "workers"]`, so a job with no
347
+ * `runOn` key runs everywhere.
348
+ */
349
+ export function filterJobsForRuntime<T extends { runOn?: CronRuntime[] }>(
350
+ jobs: T[],
351
+ runtime: CronRuntime,
352
+ ): T[] {
353
+ return jobs.filter((j) => {
354
+ const runOn = j.runOn && j.runOn.length > 0 ? j.runOn : ["bun", "workers"];
355
+ return runOn.includes(runtime);
356
+ });
357
+ }
358
+
359
+ /**
360
+ * Core constructor. Exposed for tests so they can inject a controllable fake
361
+ * scheduler and drive ticks deterministically without touching real cron.
362
+ *
363
+ * @internal
364
+ */
365
+ export function _defineCronWith(
366
+ jobs: Record<string, CronJobConfig>,
367
+ scheduleFn: CronScheduleFn,
368
+ ): CronRegistration {
369
+ const isProd =
370
+ typeof process !== "undefined" && process.env?.NODE_ENV === "production";
371
+
372
+ // Freeze the job set at definition time — no add/remove after construction.
373
+ const names = Object.keys(jobs);
374
+ const states: Map<string, JobState> = new Map();
375
+ for (const name of names) {
376
+ const config = jobs[name];
377
+ // A job is "skipped" on this Bun host if:
378
+ // (a) skipInDev=true and we're not in prod, OR
379
+ // (b) runOn is set and does not include "bun" (workers-only, etc.).
380
+ const runOn = config.runOn && config.runOn.length > 0 ? config.runOn : ["bun", "workers"];
381
+ const skipped =
382
+ (config.skipInDev === true && !isProd) || !runOn.includes("bun");
383
+ states.set(name, {
384
+ name,
385
+ config,
386
+ skipped,
387
+ handle: null,
388
+ status: {
389
+ lastRunAt: null,
390
+ lastDurationMs: null,
391
+ inFlight: false,
392
+ runCount: 0,
393
+ skipCount: 0,
394
+ errorCount: 0,
395
+ },
396
+ inFlightSettle: null,
397
+ });
398
+ }
399
+
400
+ let started = false;
401
+ let stopping = false;
402
+
403
+ function makeTickHandler(state: JobState): () => Promise<void> {
404
+ return async () => {
405
+ // No new ticks once we've started stopping.
406
+ if (stopping) return;
407
+
408
+ // Overlap prevention: if the previous invocation is still running, skip.
409
+ if (state.status.inFlight) {
410
+ state.status.skipCount += 1;
411
+ return;
412
+ }
413
+
414
+ state.status.inFlight = true;
415
+ const startedAt = Date.now();
416
+
417
+ const prefix = `[scheduler:${state.name}]`;
418
+ const ctx: CronContext = {
419
+ name: state.name,
420
+ scheduledAt: new Date(startedAt),
421
+ log: {
422
+ info: (...args: unknown[]) => { console.log(prefix, ...args); },
423
+ warn: (...args: unknown[]) => { console.warn(prefix, ...args); },
424
+ error: (...args: unknown[]) => { console.error(prefix, ...args); },
425
+ },
426
+ };
427
+
428
+ // The promise that future ticks (and `stop()`) wait on. We capture it
429
+ // in a variable so the `.finally()` can resolve the outer promise even
430
+ // if `run()` itself throws synchronously.
431
+ let settleResolve!: () => void;
432
+ const settle = new Promise<void>((r) => {
433
+ settleResolve = r;
434
+ });
435
+ state.inFlightSettle = settle;
436
+
437
+ const runAndCount = (async () => {
438
+ try {
439
+ await state.config.run(ctx);
440
+ } catch (error) {
441
+ state.status.errorCount += 1;
442
+ // Error isolation — never let a handler crash the process.
443
+ console.error(
444
+ `[scheduler] job ${state.name} failed:`,
445
+ error,
446
+ );
447
+ }
448
+ })();
449
+
450
+ // Decide whether to wait for the handler or give up after timeout.
451
+ const timeoutMs = state.config.timeoutMs;
452
+ if (typeof timeoutMs === "number" && timeoutMs > 0) {
453
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
454
+ const timeoutMarker = Symbol("timeout");
455
+ const timeoutPromise = new Promise<typeof timeoutMarker>((resolve) => {
456
+ timeoutHandle = setTimeout(() => resolve(timeoutMarker), timeoutMs);
457
+ });
458
+
459
+ const winner = await Promise.race([runAndCount.then(() => null), timeoutPromise]);
460
+
461
+ if (winner === timeoutMarker) {
462
+ // Handler is still running on its own. Log, clear inFlight so the
463
+ // next tick can fire, but do NOT attempt to cancel — Bun.cron has
464
+ // no cancellation and calling back into the handler would risk
465
+ // double-execution.
466
+ console.warn(
467
+ `[scheduler] job ${state.name} exceeded timeoutMs=${timeoutMs} — future ticks may run while the previous handler is still executing.`,
468
+ );
469
+ state.status.runCount += 1;
470
+ state.status.lastRunAt = Date.now();
471
+ state.status.lastDurationMs = Date.now() - startedAt;
472
+ state.status.inFlight = false;
473
+ settleResolve();
474
+ state.inFlightSettle = null;
475
+ return;
476
+ }
477
+
478
+ // Handler finished first — clear the timeout to avoid a leaked timer.
479
+ if (timeoutHandle !== undefined) {
480
+ clearTimeout(timeoutHandle);
481
+ }
482
+ } else {
483
+ await runAndCount;
484
+ }
485
+
486
+ state.status.runCount += 1;
487
+ state.status.lastRunAt = Date.now();
488
+ state.status.lastDurationMs = Date.now() - startedAt;
489
+ state.status.inFlight = false;
490
+ settleResolve();
491
+ state.inFlightSettle = null;
492
+ };
493
+ }
494
+
495
+ function start(): void {
496
+ if (started) return;
497
+ started = true;
498
+ stopping = false;
499
+ for (const state of states.values()) {
500
+ if (state.skipped) continue;
501
+ const tick = makeTickHandler(state);
502
+ const handle = scheduleFn(state.config.schedule, tick);
503
+ state.handle = handle ?? null;
504
+ }
505
+ }
506
+
507
+ async function stop(): Promise<void> {
508
+ if (!started) return;
509
+ stopping = true;
510
+ // Tell each underlying cron to stop firing new ticks. Handles returned
511
+ // from `Bun.cron` may be void (docs show `await Bun.cron.remove(name)` as
512
+ // the alternate shape), so we defensively handle both.
513
+ const stopPromises: Array<Promise<void>> = [];
514
+ for (const state of states.values()) {
515
+ if (state.handle && typeof state.handle.stop === "function") {
516
+ const r = state.handle.stop();
517
+ if (r && typeof (r as Promise<void>).then === "function") {
518
+ stopPromises.push(r as Promise<void>);
519
+ }
520
+ }
521
+ state.handle = null;
522
+ }
523
+ if (stopPromises.length > 0) {
524
+ await Promise.allSettled(stopPromises);
525
+ }
526
+ // Wait for any in-flight handler to settle.
527
+ const inflight: Array<Promise<void>> = [];
528
+ for (const state of states.values()) {
529
+ if (state.inFlightSettle) inflight.push(state.inFlightSettle);
530
+ }
531
+ if (inflight.length > 0) {
532
+ await Promise.allSettled(inflight);
533
+ }
534
+ started = false;
535
+ }
536
+
537
+ function status(): Record<string, CronJobStatus> {
538
+ const out: Record<string, CronJobStatus> = {};
539
+ for (const [name, state] of states) {
540
+ // Snapshot (shallow clone) so callers can't mutate internal state.
541
+ out[name] = { ...state.status };
542
+ }
543
+ return out;
544
+ }
545
+
546
+ return { start, stop, status };
547
+ }