@zerotal/scheduler 1.0.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.
@@ -0,0 +1,434 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { FrameworkEvents } from "@zerotal/core";
3
+ import { TaskRan, TaskFailed, TaskSkipped } from "./events.ts";
4
+ import type { LockManager, ManagedLock } from "@zerotal/core/lock";
5
+ import { CronExpression } from "./CronExpression.ts";
6
+ import { frameworkLog } from "@zerotal/core/logger";
7
+
8
+ // ── Output capture (shared, reference-counted) ────────────────────────────────
9
+ // A single `console.log` patch is installed while ANY task is capturing, and the
10
+ // captured output is routed to the running task's own buffer via AsyncLocalStorage.
11
+ // This replaces a per-task save/restore of `console.log`, which corrupted the
12
+ // global under overlapping captured tasks: one task restoring while another was
13
+ // still patched left a dead wrapper installed permanently, appending every future
14
+ // log to an abandoned array.
15
+ const _outputSink = new AsyncLocalStorage<string[]>();
16
+ let _captureRefs = 0;
17
+ let _originalConsoleLog: typeof console.log | undefined;
18
+
19
+ function _beginOutputCapture(): void {
20
+ _captureRefs++;
21
+ if (_captureRefs === 1) {
22
+ _originalConsoleLog = console.log;
23
+ console.log = (...args: unknown[]) => {
24
+ _outputSink.getStore()?.push(args.map(String).join(" "));
25
+ _originalConsoleLog!(...args);
26
+ };
27
+ }
28
+ }
29
+
30
+ function _endOutputCapture(): void {
31
+ _captureRefs = Math.max(0, _captureRefs - 1);
32
+ if (_captureRefs === 0 && _originalConsoleLog) {
33
+ console.log = _originalConsoleLog;
34
+ _originalConsoleLog = undefined;
35
+ }
36
+ }
37
+
38
+ export type TaskCallback = () => void | Promise<void>;
39
+ export type TaskGuard = () => boolean | Promise<boolean>;
40
+ export type TaskHook = () => void | Promise<void>;
41
+
42
+ export type OutputMailer = (email: string, subject: string, body: string) => void | Promise<void>;
43
+
44
+ export interface OverlapLockOptions {
45
+ /**
46
+ * Take a cross-process lock (via the configured lock driver) in addition to the
47
+ * in-process guard. Default: `true`. Set `false` to guard within this process only.
48
+ */
49
+ crossProcess?: boolean;
50
+
51
+ /** Lock TTL safety net, in minutes, so a crashed run can't deadlock the key. Default: 1440 (24h). */
52
+ expiresAfterMinutes?: number;
53
+ }
54
+
55
+ export class ScheduledTask {
56
+ private _name: string;
57
+ private _schedule: string;
58
+ private _callback: TaskCallback;
59
+ private _handle: { stop(): void } | undefined = undefined;
60
+ private _timezone: string | undefined = undefined;
61
+ private _running: boolean = false;
62
+ private _lastRunAt: Date | undefined = undefined;
63
+ private _lastOk: boolean | undefined = undefined;
64
+ private _lastDurationMs: number | undefined = undefined;
65
+ private _skipIfStillRunning: boolean = false;
66
+ private _onSuccess: (() => void | Promise<void>) | undefined = undefined;
67
+ private _onFailure: ((err: Error) => void | Promise<void>) | undefined = undefined;
68
+ private _outputPath: string | undefined = undefined;
69
+
70
+ private _environments: string[] | undefined = undefined;
71
+ private _between: [string, string] | undefined = undefined;
72
+ private _unlessBetween: [string, string] | undefined = undefined;
73
+ private _when: TaskGuard | undefined = undefined;
74
+ private _skip: TaskGuard | undefined = undefined;
75
+
76
+ private _runInBackground: boolean = false;
77
+
78
+ private _onStart: TaskHook | undefined = undefined;
79
+ private _pingBefore: string | undefined = undefined;
80
+ private _pingAfter: string | undefined = undefined;
81
+ private _pingOnSuccess: string | undefined = undefined;
82
+ private _pingOnFailure: string | undefined = undefined;
83
+
84
+ private _sendOutputPath: string | undefined = undefined;
85
+ private _emailOutputTo: string | undefined = undefined;
86
+
87
+ private _crossProcess: boolean = false;
88
+ private _lockTtlMs: number = 24 * 60 * 60 * 1000;
89
+ private _lockHandle: ManagedLock | undefined = undefined;
90
+
91
+ static outputMailer: OutputMailer | undefined = undefined;
92
+
93
+ /**
94
+ * Distributed lock manager used for cross-process `withoutOverlapping`. Wired by
95
+ * SchedulerProvider from the container's `lock` binding. When unset, overlap
96
+ * protection falls back to the in-process guard only.
97
+ */
98
+ static lockManager: LockManager | null = null;
99
+
100
+ constructor(name: string, schedule: string, callback: TaskCallback) {
101
+ this._name = name;
102
+ this._schedule = schedule;
103
+ this._callback = callback;
104
+ }
105
+
106
+ timezone(tz: string): this {
107
+ this._timezone = tz;
108
+ return this;
109
+ }
110
+
111
+ at(time: string): this {
112
+ const [h, m] = time.split(":").map(Number);
113
+ const parts = this._schedule.trim().split(/\s+/);
114
+ if (parts.length >= 6) {
115
+ parts[1] = String(m ?? 0);
116
+ parts[2] = String(h ?? 0);
117
+ } else {
118
+ while (parts.length < 5) parts.push("*");
119
+ parts[0] = String(m ?? 0);
120
+ parts[1] = String(h ?? 0);
121
+ }
122
+ this._schedule = parts.join(" ");
123
+ return this;
124
+ }
125
+
126
+ withoutOverlapping(options?: OverlapLockOptions): this {
127
+ this._skipIfStillRunning = true;
128
+ this._crossProcess = options?.crossProcess ?? true;
129
+ if (options?.expiresAfterMinutes !== undefined) {
130
+ this._lockTtlMs = Math.max(1, options.expiresAfterMinutes) * 60 * 1000;
131
+ }
132
+ return this;
133
+ }
134
+
135
+ environments(envs: string[]): this {
136
+ this._environments = envs;
137
+ return this;
138
+ }
139
+ between(start: string, end: string): this {
140
+ this._between = [start, end];
141
+ return this;
142
+ }
143
+ unlessBetween(start: string, end: string): this {
144
+ this._unlessBetween = [start, end];
145
+ return this;
146
+ }
147
+ when(predicate: TaskGuard): this {
148
+ this._when = predicate;
149
+ return this;
150
+ }
151
+ skip(predicate: TaskGuard): this {
152
+ this._skip = predicate;
153
+ return this;
154
+ }
155
+ runInBackground(): this {
156
+ this._runInBackground = true;
157
+ return this;
158
+ }
159
+ onStart(fn: TaskHook): this {
160
+ this._onStart = fn;
161
+ return this;
162
+ }
163
+ onSuccess(fn: () => void | Promise<void>): this {
164
+ this._onSuccess = fn;
165
+ return this;
166
+ }
167
+ onFailure(fn: (err: Error) => void | Promise<void>): this {
168
+ this._onFailure = fn;
169
+ return this;
170
+ }
171
+ pingBefore(url: string): this {
172
+ this._pingBefore = url;
173
+ return this;
174
+ }
175
+ pingAfter(url: string): this {
176
+ this._pingAfter = url;
177
+ return this;
178
+ }
179
+ pingOnSuccess(url: string): this {
180
+ this._pingOnSuccess = url;
181
+ return this;
182
+ }
183
+ pingOnFailure(url: string): this {
184
+ this._pingOnFailure = url;
185
+ return this;
186
+ }
187
+ appendOutputTo(path: string): this {
188
+ this._outputPath = path;
189
+ return this;
190
+ }
191
+ sendOutputTo(path: string): this {
192
+ this._sendOutputPath = path;
193
+ return this;
194
+ }
195
+ emailOutputTo(email: string): this {
196
+ this._emailOutputTo = email;
197
+ return this;
198
+ }
199
+
200
+ get name(): string {
201
+ return this._name;
202
+ }
203
+ get schedule(): string {
204
+ return this._schedule;
205
+ }
206
+ get isRunning(): boolean {
207
+ return this._running;
208
+ }
209
+ /** When the task last ran (undefined if never). */
210
+ get lastRunAt(): Date | undefined {
211
+ return this._lastRunAt;
212
+ }
213
+ /** Whether the last run succeeded. */
214
+ get lastOk(): boolean | undefined {
215
+ return this._lastOk;
216
+ }
217
+ /** Duration of the last run, in milliseconds. */
218
+ get lastDurationMs(): number | undefined {
219
+ return this._lastDurationMs;
220
+ }
221
+ /** Next scheduled fire time from `from` (null if the expression never matches). */
222
+ nextRunAt(from: Date = new Date()): Date | null {
223
+ return new CronExpression(this._schedule).nextRun(from);
224
+ }
225
+ /** Run the task body immediately, bypassing schedule/time-window guards. */
226
+ async runNow(): Promise<void> {
227
+ await this._execute();
228
+ }
229
+
230
+ private _currentEnv(): string {
231
+ return Bun.env["APP_ENV"] ?? Bun.env["NODE_ENV"] ?? "development";
232
+ }
233
+
234
+ private _passesEnvironment(): boolean {
235
+ if (!this._environments) return true;
236
+ return this._environments.includes(this._currentEnv());
237
+ }
238
+
239
+ private static _isWithin(now: Date, start: string, end: string): boolean {
240
+ const cur = now.getHours() * 60 + now.getMinutes();
241
+ const [sh, sm] = start.split(":").map(Number);
242
+ const [eh, em] = end.split(":").map(Number);
243
+ const s = (sh || 0) * 60 + (sm || 0);
244
+ const e = (eh || 0) * 60 + (em || 0);
245
+ return s <= e ? cur >= s && cur <= e : cur >= s || cur <= e;
246
+ }
247
+
248
+ private _passesTimeWindow(now: Date): boolean {
249
+ if (this._between && !ScheduledTask._isWithin(now, this._between[0], this._between[1]))
250
+ return false;
251
+ if (
252
+ this._unlessBetween &&
253
+ ScheduledTask._isWithin(now, this._unlessBetween[0], this._unlessBetween[1])
254
+ )
255
+ return false;
256
+ return true;
257
+ }
258
+
259
+ private async _ping(url: string): Promise<void> {
260
+ try {
261
+ await fetch(url);
262
+ } catch (err) {
263
+ frameworkLog("scheduler").error(
264
+ `Ping failed for "${this._name}" → ${url}`,
265
+ { task: this._name, url },
266
+ err,
267
+ );
268
+ }
269
+ }
270
+
271
+ private async _acquireLock(): Promise<boolean> {
272
+ if (!this._crossProcess) return true;
273
+ const manager = ScheduledTask.lockManager;
274
+ // No distributed lock configured (no LockProvider / memory driver) — the
275
+ // in-process `_running` guard already prevents same-process overlap.
276
+ if (!manager) return true;
277
+
278
+ const ttlSeconds = Math.max(1, Math.ceil(this._lockTtlMs / 1000));
279
+ const handle = manager.lock(`schedule:${this._name}`, ttlSeconds);
280
+ const acquired = await handle.acquire();
281
+ if (acquired) this._lockHandle = handle;
282
+ return acquired;
283
+ }
284
+
285
+ private async _releaseLock(): Promise<void> {
286
+ if (this._lockHandle) {
287
+ await this._lockHandle.release();
288
+ this._lockHandle = undefined;
289
+ }
290
+ }
291
+
292
+ private async _execute(): Promise<void> {
293
+ const capture = !!(this._outputPath || this._sendOutputPath || this._emailOutputTo);
294
+ const lines: string[] = [];
295
+ const _t0 = Date.now();
296
+ const _perf0 = performance.now();
297
+ this._lastRunAt = new Date();
298
+ this._lastOk = true;
299
+ if (capture) _beginOutputCapture();
300
+
301
+ const body = async (): Promise<void> => {
302
+ if (this._onStart) await this._onStart();
303
+ if (this._pingBefore) await this._ping(this._pingBefore);
304
+ await this._callback();
305
+ if (this._onSuccess) await this._onSuccess();
306
+ if (this._pingOnSuccess) await this._ping(this._pingOnSuccess);
307
+ FrameworkEvents.emit(new TaskRan(this._name, performance.now() - _perf0, true));
308
+ };
309
+
310
+ try {
311
+ // Scope this task's captured output to its own buffer via AsyncLocalStorage,
312
+ // so overlapping captured tasks never cross-contaminate.
313
+ if (capture) await _outputSink.run(lines, body);
314
+ else await body();
315
+ } catch (rawErr) {
316
+ const err = rawErr instanceof Error ? rawErr : new Error(String(rawErr));
317
+ this._lastOk = false;
318
+ frameworkLog("scheduler").error(`Task "${this._name}" failed`, { task: this._name }, err);
319
+ FrameworkEvents.emit(new TaskFailed(this._name, performance.now() - _perf0, err.message));
320
+ if (this._onFailure) await this._onFailure(err);
321
+ if (this._pingOnFailure) await this._ping(this._pingOnFailure);
322
+ } finally {
323
+ this._lastDurationMs = Date.now() - _t0;
324
+ if (this._pingAfter) await this._ping(this._pingAfter);
325
+ if (capture) {
326
+ _endOutputCapture();
327
+ if (lines.length > 0) {
328
+ const timestamp = new Date().toISOString();
329
+ const entry = lines.map((l) => `[${timestamp}] ${l}`).join("\n") + "\n";
330
+ if (this._outputPath) {
331
+ const existing = await Bun.file(this._outputPath)
332
+ .text()
333
+ .catch(() => "");
334
+ await Bun.write(this._outputPath, existing + entry);
335
+ }
336
+ if (this._sendOutputPath) await Bun.write(this._sendOutputPath, entry);
337
+ if (this._emailOutputTo) await this._emailOutput(this._emailOutputTo, lines.join("\n"));
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ private async _emailOutput(email: string, body: string): Promise<void> {
344
+ const subject = `[Zerotal Scheduler] Output: ${this._name}`;
345
+ if (ScheduledTask.outputMailer) {
346
+ try {
347
+ await ScheduledTask.outputMailer(email, subject, body);
348
+ } catch (err) {
349
+ frameworkLog("scheduler").error(
350
+ `emailOutputTo failed for "${this._name}"`,
351
+ { task: this._name },
352
+ err,
353
+ );
354
+ }
355
+ } else {
356
+ frameworkLog("scheduler").info(`(no outputMailer set) would email "${email}":\n${body}`, {
357
+ task: this._name,
358
+ });
359
+ }
360
+ }
361
+
362
+ protected _buildHandler(): () => Promise<void> {
363
+ return async () => {
364
+ if (!this._passesEnvironment()) {
365
+ FrameworkEvents.emit(new TaskSkipped(this._name, "env"));
366
+ return;
367
+ }
368
+ if (!this._passesTimeWindow(new Date())) {
369
+ FrameworkEvents.emit(new TaskSkipped(this._name, "window"));
370
+ return;
371
+ }
372
+ if (this._when && !(await this._when())) {
373
+ FrameworkEvents.emit(new TaskSkipped(this._name, "when"));
374
+ return;
375
+ }
376
+ if (this._skip && (await this._skip())) {
377
+ FrameworkEvents.emit(new TaskSkipped(this._name, "skip"));
378
+ return;
379
+ }
380
+
381
+ if (this._skipIfStillRunning && this._running) {
382
+ frameworkLog("scheduler").info(`Skipping "${this._name}" — previous run still active`, {
383
+ task: this._name,
384
+ });
385
+ FrameworkEvents.emit(new TaskSkipped(this._name, "overlap"));
386
+ return;
387
+ }
388
+ if (!(await this._acquireLock())) {
389
+ frameworkLog("scheduler").info(`Skipping "${this._name}" — lock held by another process`, {
390
+ task: this._name,
391
+ });
392
+ FrameworkEvents.emit(new TaskSkipped(this._name, "lock"));
393
+ return;
394
+ }
395
+
396
+ this._running = true;
397
+
398
+ if (this._runInBackground) {
399
+ void this._execute().finally(() => {
400
+ this._running = false;
401
+ void this._releaseLock();
402
+ });
403
+ return;
404
+ }
405
+
406
+ try {
407
+ await this._execute();
408
+ } finally {
409
+ this._running = false;
410
+ await this._releaseLock();
411
+ }
412
+ };
413
+ }
414
+
415
+ start(): void {
416
+ if (this._handle) return;
417
+ const handler = this._buildHandler();
418
+ // Bun.cron supports croner's options form `(schedule, { run, timezone })` at
419
+ // runtime, but @types/bun only types the `(schedule, handler)` overload — cast
420
+ // when passing a timezone.
421
+ const cronWithOptions = Bun.cron as unknown as (
422
+ schedule: string,
423
+ options: { run: () => void | Promise<void>; timezone: string },
424
+ ) => { stop(): void };
425
+ this._handle = this._timezone
426
+ ? cronWithOptions(this._schedule, { run: handler, timezone: this._timezone })
427
+ : Bun.cron(this._schedule, handler);
428
+ }
429
+
430
+ stop(): void {
431
+ this._handle?.stop();
432
+ this._handle = undefined;
433
+ }
434
+ }
@@ -0,0 +1,182 @@
1
+ import { ScheduledTask } from "./ScheduledTask.ts";
2
+ import type { TaskCallback } from "./ScheduledTask.ts";
3
+ import { frameworkLog } from "@zerotal/core/logger";
4
+
5
+ export class SchedulerManager {
6
+ private _tasks: Map<string, ScheduledTask> = new Map();
7
+ private _started: boolean = false;
8
+
9
+ add(name: string, cronExpression: string, callback: TaskCallback): ScheduledTask {
10
+ const task = new ScheduledTask(name, cronExpression, callback);
11
+ this._tasks.set(name, task);
12
+ if (this._started) task.start();
13
+ return task;
14
+ }
15
+
16
+ job(name: string, callback: TaskCallback): SchedulerBuilder {
17
+ return new SchedulerBuilder(this, name, callback);
18
+ }
19
+
20
+ start(): void {
21
+ this._started = true;
22
+ for (const task of this._tasks.values()) {
23
+ task.start();
24
+ frameworkLog("scheduler").info(`Started "${task.name}" — ${task.schedule}`);
25
+ }
26
+ }
27
+
28
+ stop(): void {
29
+ for (const task of this._tasks.values()) task.stop();
30
+ this._started = false;
31
+ frameworkLog("scheduler").info("All tasks stopped");
32
+ }
33
+
34
+ get tasks(): ReadonlyMap<string, ScheduledTask> {
35
+ return this._tasks;
36
+ }
37
+ }
38
+
39
+ export class SchedulerBuilder {
40
+ constructor(
41
+ private _manager: SchedulerManager,
42
+ private _name: string,
43
+ private _callback: TaskCallback,
44
+ ) {}
45
+
46
+ private static _hm(time: string): [number, number] {
47
+ const [h, m] = time.split(":").map(Number);
48
+ return [h || 0, m || 0];
49
+ }
50
+
51
+ cron(expression: string): ScheduledTask {
52
+ return this._manager.add(this._name, expression, this._callback);
53
+ }
54
+
55
+ everySecond(): ScheduledTask {
56
+ return this.cron("* * * * * *");
57
+ }
58
+ everyTwoSeconds(): ScheduledTask {
59
+ return this.cron("*/2 * * * * *");
60
+ }
61
+ everyFiveSeconds(): ScheduledTask {
62
+ return this.cron("*/5 * * * * *");
63
+ }
64
+ everyTenSeconds(): ScheduledTask {
65
+ return this.cron("*/10 * * * * *");
66
+ }
67
+ everyThirtySeconds(): ScheduledTask {
68
+ return this.cron("*/30 * * * * *");
69
+ }
70
+
71
+ everyMinute(): ScheduledTask {
72
+ return this.cron("* * * * *");
73
+ }
74
+ everyTwoMinutes(): ScheduledTask {
75
+ return this.cron("*/2 * * * *");
76
+ }
77
+ everyThreeMinutes(): ScheduledTask {
78
+ return this.cron("*/3 * * * *");
79
+ }
80
+ everyFourMinutes(): ScheduledTask {
81
+ return this.cron("*/4 * * * *");
82
+ }
83
+ everyFiveMinutes(): ScheduledTask {
84
+ return this.cron("*/5 * * * *");
85
+ }
86
+ everyTenMinutes(): ScheduledTask {
87
+ return this.cron("*/10 * * * *");
88
+ }
89
+ everyFifteenMinutes(): ScheduledTask {
90
+ return this.cron("*/15 * * * *");
91
+ }
92
+ everyThirtyMinutes(): ScheduledTask {
93
+ return this.cron("*/30 * * * *");
94
+ }
95
+
96
+ hourly(): ScheduledTask {
97
+ return this.cron("0 * * * *");
98
+ }
99
+ hourlyAt(minute: number): ScheduledTask {
100
+ return this.cron(`${minute} * * * *`);
101
+ }
102
+ everyOddHour(): ScheduledTask {
103
+ return this.cron("0 1-23/2 * * *");
104
+ }
105
+ daily(): ScheduledTask {
106
+ return this.cron("0 0 * * *");
107
+ }
108
+ dailyAt(time: string): ScheduledTask {
109
+ const [h, m] = SchedulerBuilder._hm(time);
110
+ return this.cron(`${m} ${h} * * *`);
111
+ }
112
+ twiceDaily(first = 1, second = 13): ScheduledTask {
113
+ return this.cron(`0 ${first},${second} * * *`);
114
+ }
115
+
116
+ weekdays(): ScheduledTask {
117
+ return this.cron("0 0 * * 1-5");
118
+ }
119
+ weekends(): ScheduledTask {
120
+ return this.cron("0 0 * * 6,0");
121
+ }
122
+ sundays(): ScheduledTask {
123
+ return this.cron("0 0 * * 0");
124
+ }
125
+ mondays(): ScheduledTask {
126
+ return this.cron("0 0 * * 1");
127
+ }
128
+ tuesdays(): ScheduledTask {
129
+ return this.cron("0 0 * * 2");
130
+ }
131
+ wednesdays(): ScheduledTask {
132
+ return this.cron("0 0 * * 3");
133
+ }
134
+ thursdays(): ScheduledTask {
135
+ return this.cron("0 0 * * 4");
136
+ }
137
+ fridays(): ScheduledTask {
138
+ return this.cron("0 0 * * 5");
139
+ }
140
+ saturdays(): ScheduledTask {
141
+ return this.cron("0 0 * * 6");
142
+ }
143
+ days(daysOfWeek: number[]): ScheduledTask {
144
+ return this.cron(`0 0 * * ${daysOfWeek.join(",")}`);
145
+ }
146
+
147
+ weekly(): ScheduledTask {
148
+ return this.cron("0 0 * * 0");
149
+ }
150
+ twiceWeekly(first = 1, second = 4): ScheduledTask {
151
+ return this.cron(`0 0 * * ${first},${second}`);
152
+ }
153
+ monthly(): ScheduledTask {
154
+ return this.cron("0 0 1 * *");
155
+ }
156
+ twiceMonthly(first = 1, second = 16): ScheduledTask {
157
+ return this.cron(`0 0 ${first},${second} * *`);
158
+ }
159
+ lastDayOfMonth(time = "00:00"): ScheduledTask {
160
+ const [h, m] = SchedulerBuilder._hm(time);
161
+ const task = this.cron(`${m} ${h} 28-31 * *`);
162
+ return task.when(() => {
163
+ const d = new Date();
164
+ const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
165
+ return d.getDate() === lastDay;
166
+ });
167
+ }
168
+ quarterly(): ScheduledTask {
169
+ return this.cron("0 0 1 1,4,7,10 *");
170
+ }
171
+ quarterlyOn(dayOfMonth = 1, time = "00:00"): ScheduledTask {
172
+ const [h, m] = SchedulerBuilder._hm(time);
173
+ return this.cron(`${m} ${h} ${dayOfMonth} 1,4,7,10 *`);
174
+ }
175
+ yearly(): ScheduledTask {
176
+ return this.cron("0 0 1 1 *");
177
+ }
178
+ yearlyOn(month = 1, dayOfMonth = 1, time = "00:00"): ScheduledTask {
179
+ const [h, m] = SchedulerBuilder._hm(time);
180
+ return this.cron(`${m} ${h} ${dayOfMonth} ${month} *`);
181
+ }
182
+ }
@@ -0,0 +1,48 @@
1
+ import type { Application } from "@zerotal/core";
2
+ import { Command } from "@zerotal/core";
3
+ import { CronExpression } from "../CronExpression.ts";
4
+ import type { SchedulerManager } from "../SchedulerManager.ts";
5
+
6
+ export class ScheduleListCommand extends Command {
7
+ static override commandName = "schedule:list";
8
+ static override description = "List scheduled tasks with their next run time";
9
+ static override needsApp = true;
10
+
11
+ async run(): Promise<void> {
12
+ const app = this.app as Application | undefined;
13
+ const scheduler = app?.container.tryMake("scheduler") as SchedulerManager | undefined;
14
+
15
+ if (!scheduler) {
16
+ this.error("Scheduler not registered. Add SchedulerProvider to your providers.");
17
+ return;
18
+ }
19
+
20
+ const schedulesFile = `${process.cwd()}/app/schedules.ts`;
21
+ if (await Bun.file(schedulesFile).exists()) {
22
+ try {
23
+ await import(schedulesFile);
24
+ } catch (err) {
25
+ this.warn(`Failed to load app/schedules.ts: ${(err as Error).message}`);
26
+ }
27
+ }
28
+
29
+ const tasks = [...scheduler.tasks.values()];
30
+ if (tasks.length === 0) {
31
+ this.info("No scheduled tasks registered.");
32
+ return;
33
+ }
34
+
35
+ const now = new Date();
36
+ this.section(`Scheduled tasks (${tasks.length})`);
37
+ for (const task of tasks) {
38
+ const next = CronExpression.nextRunAfter(task.schedule, now);
39
+ this.table([
40
+ ["Name", task.name],
41
+ ["Expression", task.schedule],
42
+ ["Description", CronExpression.describe(task.schedule)],
43
+ ["Next run", next ? next.toISOString() : "—"],
44
+ ]);
45
+ this.newLine();
46
+ }
47
+ }
48
+ }
@@ -0,0 +1 @@
1
+ export { ScheduleListCommand } from "./ScheduleListCommand.ts";
package/src/config.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { deepMerge } from "@zerotal/core";
2
+
3
+ export interface SchedulerConfigShape {
4
+ /** Timezone for cron expressions. Informational only - Bun.cron uses
5
+ * the system timezone. Default: 'UTC' */
6
+ timezone: string;
7
+ }
8
+
9
+ const defaults: SchedulerConfigShape = {
10
+ timezone: "UTC",
11
+ };
12
+
13
+ /**
14
+ * Create a typed scheduler configuration object with defaults.
15
+ *
16
+ * @example
17
+ * import { SchedulerConfig } from '@zerotal/scheduler';
18
+ * export default SchedulerConfig({ timezone: 'Africa/Johannesburg' });
19
+ */
20
+ export function SchedulerConfig(options: Partial<SchedulerConfigShape> = {}): SchedulerConfigShape {
21
+ return deepMerge(defaults, options);
22
+ }
23
+
24
+ // Register this package's config namespace for typed config() dot-paths.
25
+ declare module "@zerotal/core" {
26
+ interface ConfigRegistry {
27
+ scheduler: SchedulerConfigShape;
28
+ }
29
+ }