@mandujs/core 0.30.0 → 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.
- package/package.json +4 -1
- package/src/client/index.ts +11 -1
- package/src/client/rpc.ts +293 -140
- package/src/config/mandu.ts +49 -0
- package/src/config/validate.ts +48 -0
- package/src/contract/index.ts +18 -0
- package/src/contract/rpc.ts +443 -0
- package/src/middleware/index.ts +7 -0
- package/src/middleware/scheduler-cron.ts +96 -0
- package/src/runtime/server.ts +155 -0
- package/src/scheduler/index.ts +547 -343
- package/src/scheduler/validate.ts +169 -0
package/src/scheduler/index.ts
CHANGED
|
@@ -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
|
-
|
|
58
|
-
export
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
interface
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
*/
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
}
|