@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,81 @@
1
+ import type { ConcernDescriptor } from "@zerotal/core";
2
+ import { Schedule } from "./Schedule.ts";
3
+ import type { SchedulerManager } from "./SchedulerManager.ts";
4
+ import type { ScheduledTask } from "./ScheduledTask.ts";
5
+ import { frameworkLog } from "@zerotal/core/logger";
6
+
7
+ function isScheduleClass(v: unknown): v is new () => Schedule {
8
+ return (
9
+ typeof v === "function" &&
10
+ v !== Schedule &&
11
+ (v as { prototype?: unknown }).prototype instanceof Schedule
12
+ );
13
+ }
14
+
15
+ /**
16
+ * Register one Schedule instance with the scheduler manager, translating its declarative
17
+ * settings into a configured ScheduledTask. Exported for testing.
18
+ */
19
+ export function registerSchedule(
20
+ manager: SchedulerManager,
21
+ schedule: Schedule,
22
+ fallbackName: string,
23
+ ): ScheduledTask | undefined {
24
+ const name = schedule.name ?? fallbackName;
25
+ const builder = manager.job(name, () => schedule.handle());
26
+
27
+ let task: ScheduledTask;
28
+ if (typeof schedule.frequency === "function") {
29
+ task = schedule.frequency(builder);
30
+ } else if (schedule.cron) {
31
+ task = builder.cron(schedule.cron);
32
+ } else {
33
+ frameworkLog("scheduler").warn(`Schedule "${name}" defines no cron or frequency(); skipped`);
34
+ return undefined;
35
+ }
36
+
37
+ if (schedule.timezone) task.timezone(schedule.timezone);
38
+ if (schedule.withoutOverlapping) {
39
+ task.withoutOverlapping(
40
+ typeof schedule.withoutOverlapping === "object" ? schedule.withoutOverlapping : undefined,
41
+ );
42
+ }
43
+ if (schedule.environments) task.environments(schedule.environments);
44
+ if (schedule.inBackground) task.runInBackground();
45
+ if (schedule.between) task.between(schedule.between[0], schedule.between[1]);
46
+ if (schedule.unlessBetween)
47
+ task.unlessBetween(schedule.unlessBetween[0], schedule.unlessBetween[1]);
48
+ if (schedule.pingBefore) task.pingBefore(schedule.pingBefore);
49
+ if (schedule.pingAfter) task.pingAfter(schedule.pingAfter);
50
+ if (schedule.pingOnSuccess) task.pingOnSuccess(schedule.pingOnSuccess);
51
+ if (schedule.pingOnFailure) task.pingOnFailure(schedule.pingOnFailure);
52
+ if (schedule.appendOutputTo) task.appendOutputTo(schedule.appendOutputTo);
53
+ if (schedule.emailOutputTo) task.emailOutputTo(schedule.emailOutputTo);
54
+ if (typeof schedule.when === "function") task.when(() => schedule.when!());
55
+ if (typeof schedule.skip === "function") task.skip(() => schedule.skip!());
56
+
57
+ return task;
58
+ }
59
+
60
+ /**
61
+ * `app/schedules/` convention. Every `Schedule` subclass is instantiated and registered with the
62
+ * scheduler. Runs in worker (to execute) and console (so `schedule:list` can enumerate them);
63
+ * never in `web`, so HTTP instances don't run cron. Contributed by `SchedulerProvider`.
64
+ */
65
+ export const schedulesConcern: ConcernDescriptor = {
66
+ name: "schedules",
67
+ order: 55,
68
+ dir: "app/schedules",
69
+ envs: ["worker", "console"],
70
+ register(mod, ctx) {
71
+ const manager = ctx.resolve<SchedulerManager>("scheduler");
72
+ if (!manager) {
73
+ frameworkLog("scheduler").warn('app/schedules: "scheduler" binding not available; skipped');
74
+ return;
75
+ }
76
+ for (const exported of Object.values(mod)) {
77
+ if (!isScheduleClass(exported)) continue;
78
+ registerSchedule(manager, new exported(), exported.name);
79
+ }
80
+ },
81
+ };
package/src/events.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The scheduler package's framework events, emitted on core's {@link FrameworkEvents}
3
+ * bus. Observability packages subscribe to them by kind (their class name).
4
+ */
5
+
6
+ /**
7
+ * Emitted after a scheduled task finishes running.
8
+ * @category Scheduler
9
+ */
10
+ export class TaskRan {
11
+ constructor(
12
+ readonly name: string,
13
+ readonly durationMs: number,
14
+ readonly ok: boolean,
15
+ ) {}
16
+ }
17
+
18
+ /**
19
+ * Emitted when a scheduled task throws during execution.
20
+ * @category Scheduler
21
+ */
22
+ export class TaskFailed {
23
+ constructor(
24
+ readonly name: string,
25
+ readonly durationMs: number,
26
+ readonly error: string,
27
+ ) {}
28
+ }
29
+
30
+ /**
31
+ * Emitted when a scheduled task is skipped before running; `reason` records
32
+ * which guard skipped it (env, time window, `when()` condition, explicit skip,
33
+ * overlap prevention, or a held lock).
34
+ * @category Scheduler
35
+ */
36
+ export class TaskSkipped {
37
+ constructor(
38
+ readonly name: string,
39
+ readonly reason: "env" | "window" | "when" | "skip" | "overlap" | "lock",
40
+ ) {}
41
+ }
@@ -0,0 +1,11 @@
1
+ import { createFacade } from "@zerotal/core";
2
+
3
+ /**
4
+ * Static accessor for the scheduler manager. Use for fluent, inline schedule definitions
5
+ * (e.g. inside a provider). For convention-based schedules, extend the `Schedule` base class
6
+ * and place the file in `app/schedules/`.
7
+ *
8
+ * @example
9
+ * Scheduler.job("cleanup", () => purgeTempFiles()).dailyAt("02:00");
10
+ */
11
+ export const Scheduler = createFacade("scheduler");
@@ -0,0 +1,30 @@
1
+ // Ambient declarations specific to this package.
2
+ // Bun, Node (node:*), and bun:test types come from @types/bun (→ bun-types).
3
+ // Only declarations bun-types does NOT provide are kept here.
4
+
5
+ // ── Bun globals ───────────────────────────────────────────────────────────
6
+ // Bun extends Request with native route params (e.g. /users/:id → { id: '42' })
7
+ interface Request {
8
+ readonly params?: Record<string, string>;
9
+ }
10
+
11
+ // ── SQLInstance: callable tagged-template + DB methods ────────────────────
12
+ interface SQLInstance {
13
+ <T = Record<string, unknown>>(
14
+ strings: TemplateStringsArray,
15
+ ...values: unknown[]
16
+ ): Promise<T[]>;
17
+ begin<T>(fn: (tx: SQLInstance) => Promise<T>): Promise<T>;
18
+ end(): Promise<void>;
19
+ }
20
+
21
+ // ── RedisInstance: minimal Bun.redis surface ──────────────────────────────
22
+ interface RedisInstance {
23
+ get(key: string): Promise<string | null>;
24
+ set(key: string, value: string): Promise<void>;
25
+ set(key: string, value: string, options: { ex: number }): Promise<void>;
26
+ del(...keys: string[]): Promise<number>;
27
+ expire(key: string, seconds: number): Promise<number>;
28
+ keys(pattern: string): Promise<string[]>;
29
+ flushdb(): Promise<void>;
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ export { SchedulerManager, SchedulerBuilder } from "./SchedulerManager.ts";
2
+ export { ScheduledTask } from "./ScheduledTask.ts";
3
+ export { CronExpression } from "./CronExpression.ts";
4
+ export { SchedulerProvider } from "./provider/SchedulerProvider.ts";
5
+
6
+ // Convention-based scheduling: extend Schedule, drop it in app/schedules/.
7
+ export { Schedule } from "./Schedule.ts";
8
+ export { schedulesConcern, registerSchedule } from "./conventions.ts";
9
+
10
+ // Fluent inline scheduling facade (renamed from the former `Schedule` facade).
11
+ export { Scheduler } from "./facades/Scheduler.ts";
12
+
13
+ export type {
14
+ TaskCallback,
15
+ TaskGuard,
16
+ TaskHook,
17
+ OutputMailer,
18
+ OverlapLockOptions,
19
+ } from "./ScheduledTask.ts";
20
+
21
+ // Config factory
22
+ export { SchedulerConfig } from "./config.ts";
23
+ export type { SchedulerConfigShape } from "./config.ts";
24
+
25
+ // Framework instrumentation events (emitted on the core FrameworkEvents bus)
26
+ export { TaskRan, TaskFailed, TaskSkipped } from "./events.ts";
package/src/monitor.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Scheduler → monitor panel contribution.
3
+ *
4
+ * A cron task that silently stopped firing is one of the harder failures to
5
+ * notice: nothing errors, work just stops happening. Every task already tracks
6
+ * when it last ran, whether it succeeded, how long it took and when it is due
7
+ * next — this puts that in front of an operator.
8
+ *
9
+ * The panel's write surface is resolved from the container by binding key and
10
+ * typed through a local structural interface, exactly as the observer bridges in
11
+ * `observability.ts` are: the scheduler depends on `@zerotal/monitor` not at
12
+ * all, and an app running tasks without the panel pulls in nothing extra.
13
+ */
14
+ import type { Application } from "@zerotal/core";
15
+ import type { SchedulerManager } from "./SchedulerManager.ts";
16
+
17
+ type Row = Record<string, unknown>;
18
+ type Tone = "default" | "good" | "warn" | "bad";
19
+
20
+ /** The slice of the monitor panel's contribution surface this module uses. */
21
+ interface MonitorPanelSink {
22
+ enabled(id: string): boolean;
23
+ section(section: {
24
+ id: string;
25
+ label: string;
26
+ group?: string;
27
+ sort?: number;
28
+ resolve(range: string): {
29
+ stats?: Array<{
30
+ label: string;
31
+ value: string | number;
32
+ detail?: string;
33
+ tone?: Tone;
34
+ percent?: number;
35
+ }>;
36
+ tables?: Array<{
37
+ title: string;
38
+ columns: Array<{
39
+ key: string;
40
+ label: string;
41
+ align?: "start" | "end";
42
+ mono?: boolean;
43
+ format?: (value: unknown, row: Row) => string;
44
+ tone?: (value: unknown, row: Row) => Tone | null;
45
+ }>;
46
+ rows: Row[];
47
+ empty?: string;
48
+ }>;
49
+ };
50
+ }): void;
51
+ }
52
+
53
+ function formatWhen(value: unknown): string {
54
+ if (value === null || value === undefined) return "—";
55
+ const date = value instanceof Date ? value : new Date(String(value));
56
+ return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString();
57
+ }
58
+
59
+ function formatDuration(value: unknown): string {
60
+ if (typeof value !== "number") return "—";
61
+ return value < 1000 ? `${Math.round(value)} ms` : `${(value / 1000).toFixed(1)} s`;
62
+ }
63
+
64
+ /**
65
+ * Contribute the scheduled-tasks section to the monitor panel, when one is
66
+ * installed. Call from `SchedulerProvider.onBooting()`.
67
+ */
68
+ export function installSchedulerMonitor(app: Application): void {
69
+ const panel = app.container.tryMake("monitor.panel" as never) as MonitorPanelSink | undefined;
70
+ if (!panel?.enabled("scheduler")) return;
71
+
72
+ panel.section({
73
+ id: "scheduler",
74
+ label: "Scheduled tasks",
75
+ group: "Infrastructure",
76
+ // The manager is resolved at render time rather than captured here: tasks
77
+ // can be registered after boot, and the panel re-reads on every refresh.
78
+ // Going through the container rather than the facade also keeps this
79
+ // callable outside a booted app, which is what makes it testable.
80
+ resolve: () => {
81
+ const scheduler = app.container.tryMake("scheduler") as SchedulerManager | null;
82
+ const tasks = scheduler ? [...scheduler.tasks.values()] : [];
83
+ const failing = tasks.filter((t) => t.lastOk === false);
84
+ const neverRun = tasks.filter((t) => t.lastRunAt === undefined);
85
+
86
+ return {
87
+ stats: [
88
+ { label: "Tasks", value: tasks.length },
89
+ {
90
+ label: "Running now",
91
+ value: tasks.filter((t) => t.isRunning).length,
92
+ },
93
+ {
94
+ label: "Failing",
95
+ value: failing.length,
96
+ tone: failing.length > 0 ? "bad" : "good",
97
+ ...(failing.length > 0 ? { detail: failing.map((t) => t.name).join(", ") } : {}),
98
+ },
99
+ {
100
+ label: "Never run",
101
+ value: neverRun.length,
102
+ tone: neverRun.length > 0 ? "warn" : "default",
103
+ },
104
+ ],
105
+ tables: [
106
+ {
107
+ title: "Tasks",
108
+ empty: "No tasks are scheduled.",
109
+ columns: [
110
+ { key: "name", label: "Task" },
111
+ { key: "schedule", label: "Cron", mono: true },
112
+ {
113
+ key: "status",
114
+ label: "Last result",
115
+ tone: (v) => (v === "Failed" ? "bad" : v === "OK" ? "good" : null),
116
+ },
117
+ { key: "lastRunAt", label: "Last run", format: formatWhen },
118
+ { key: "durationMs", label: "Duration", align: "end", format: formatDuration },
119
+ { key: "nextRunAt", label: "Next run", format: formatWhen },
120
+ ],
121
+ rows: tasks.map((t) => ({
122
+ name: t.name,
123
+ schedule: t.schedule,
124
+ status: t.isRunning
125
+ ? "Running"
126
+ : t.lastOk === undefined
127
+ ? "Never run"
128
+ : t.lastOk
129
+ ? "OK"
130
+ : "Failed",
131
+ lastRunAt: t.lastRunAt ?? null,
132
+ durationMs: t.lastDurationMs ?? null,
133
+ nextRunAt: t.nextRunAt(),
134
+ })),
135
+ },
136
+ ],
137
+ };
138
+ },
139
+ });
140
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Scheduler → observer bridges. The scheduler emits its own `TaskRan` / `TaskFailed`
3
+ * / `TaskSkipped` framework events on the core `FrameworkEvents` bus; this module
4
+ * forwards them to whichever observer packages are installed.
5
+ *
6
+ * Each observer's write surface is resolved from the container by binding key and
7
+ * typed through a local structural interface, so the scheduler depends on none of
8
+ * the observer packages — installing or removing an observer requires no change here.
9
+ */
10
+ import { FrameworkEvents } from "@zerotal/core";
11
+ import type { Application } from "@zerotal/core";
12
+ import { TaskRan, TaskFailed, TaskSkipped } from "./events.ts";
13
+
14
+ /** The subset of the telemetry tracer this bridge calls (bound as `telemetry`). */
15
+ interface TelemetrySink {
16
+ recordCompleted(
17
+ name: string,
18
+ durationMs: number,
19
+ options?: {
20
+ kind?: "internal" | "server" | "client" | "producer" | "consumer";
21
+ attributes?: Record<string, string | number | boolean>;
22
+ status?: "ok" | "error";
23
+ errorMessage?: string;
24
+ },
25
+ ): unknown;
26
+ }
27
+
28
+ /** The subset of the monitor store this bridge calls (bound as `monitor.store`). */
29
+ interface MonitorSink {
30
+ recordEvent(e: {
31
+ kind: string;
32
+ label: string;
33
+ status?: "ok" | "warn" | "bad" | "info";
34
+ route?: string | null;
35
+ data?: Record<string, unknown>;
36
+ }): void;
37
+ }
38
+
39
+ /** The subset of the logger this bridge calls (bound as `log`). */
40
+ interface LogSink {
41
+ info(message: string, context?: Record<string, unknown>): void;
42
+ error(message: string, context?: Record<string, unknown>, error?: unknown): void;
43
+ }
44
+
45
+ /**
46
+ * Subscribe the scheduler's events to every installed observer. Returns a disposer
47
+ * that removes every subscription; call it from the scheduler provider's `onStopping()`.
48
+ */
49
+ export function installSchedulerObservability(app: Application): () => void {
50
+ const unsubs: Array<() => void> = [];
51
+
52
+ const tracer = app.container.tryMake("telemetry" as never) as TelemetrySink | undefined;
53
+ if (tracer) {
54
+ unsubs.push(
55
+ FrameworkEvents.on(TaskRan, (e) => {
56
+ void tracer.recordCompleted("schedule.task", e.durationMs, {
57
+ attributes: { "task.name": e.name },
58
+ status: e.ok ? "ok" : "error",
59
+ });
60
+ }),
61
+ FrameworkEvents.on(TaskFailed, (e) => {
62
+ void tracer.recordCompleted("schedule.task", e.durationMs, {
63
+ attributes: { "task.name": e.name },
64
+ status: "error",
65
+ errorMessage: e.error,
66
+ });
67
+ }),
68
+ );
69
+ }
70
+
71
+ const store = app.container.tryMake("monitor.store" as never) as MonitorSink | undefined;
72
+ if (store) {
73
+ unsubs.push(
74
+ FrameworkEvents.on(TaskRan, (e) =>
75
+ store.recordEvent({
76
+ kind: "task",
77
+ label: e.name,
78
+ status: e.ok ? "ok" : "bad",
79
+ route: null,
80
+ data: { ms: e.durationMs },
81
+ }),
82
+ ),
83
+ FrameworkEvents.on(TaskFailed, (e) =>
84
+ store.recordEvent({
85
+ kind: "task",
86
+ label: e.name,
87
+ status: "bad",
88
+ route: null,
89
+ data: { ms: e.durationMs, detail: e.error },
90
+ }),
91
+ ),
92
+ FrameworkEvents.on(TaskSkipped, (e) =>
93
+ store.recordEvent({
94
+ kind: "task",
95
+ label: e.name,
96
+ status: "info",
97
+ route: null,
98
+ data: { detail: e.reason },
99
+ }),
100
+ ),
101
+ );
102
+ }
103
+
104
+ const log = app.container.tryMake("log" as never) as LogSink | undefined;
105
+ if (log) {
106
+ unsubs.push(
107
+ FrameworkEvents.on(TaskRan, (e) => {
108
+ if (e.ok) log.info("Scheduled task ran", { name: e.name, durationMs: e.durationMs });
109
+ else log.error("Scheduled task failed", { name: e.name, durationMs: e.durationMs });
110
+ }),
111
+ FrameworkEvents.on(TaskFailed, (e) =>
112
+ log.error(
113
+ "Scheduled task threw",
114
+ { name: e.name, durationMs: e.durationMs },
115
+ new Error(e.error),
116
+ ),
117
+ ),
118
+ );
119
+ }
120
+
121
+ return () => {
122
+ for (const unsub of unsubs) unsub();
123
+ };
124
+ }
@@ -0,0 +1,65 @@
1
+ import { ServiceProvider } from "@zerotal/core";
2
+ import type { AppEnvironment } from "@zerotal/core";
3
+ import type { LockManager } from "@zerotal/core/lock";
4
+ import { SchedulerManager } from "../SchedulerManager.ts";
5
+ import { ScheduledTask } from "../ScheduledTask.ts";
6
+ import { schedulesConcern } from "../conventions.ts";
7
+ import { installSchedulerObservability } from "../observability.ts";
8
+ import { installSchedulerMonitor } from "../monitor.ts";
9
+
10
+ declare module "@zerotal/core" {
11
+ interface ContainerBindings {
12
+ scheduler: SchedulerManager;
13
+ }
14
+ }
15
+
16
+ export class SchedulerProvider extends ServiceProvider {
17
+ static override provides = ["scheduler"] as const;
18
+ static override environments: AppEnvironment[] = ["web", "console", "worker"];
19
+
20
+ private _disposeObservability: (() => void) | undefined = undefined;
21
+
22
+ override onRegister(): void {
23
+ // Convention-based auto-discovery of app/schedules. (Optional-chained so bare-container
24
+ // unit tests with a minimal app stub don't need to stub registerConcern.)
25
+ this.app.registerConcern?.(schedulesConcern);
26
+
27
+ this.app.container.singleton("scheduler", () => new SchedulerManager());
28
+ }
29
+
30
+ override async onBooting(): Promise<void> {
31
+ await this.app.container.make("scheduler");
32
+
33
+ // Offer the scheduled-tasks section to the monitor panel, if one is
34
+ // installed. The panel reads its section registry when it renders, so
35
+ // contributing during the booting phase is early enough.
36
+ installSchedulerMonitor(this.app);
37
+ }
38
+
39
+ override async onBooted(): Promise<void> {
40
+ this._disposeObservability = installSchedulerObservability(this.app);
41
+
42
+ // Wire the distributed lock for cross-process withoutOverlapping. Falls back to
43
+ // the in-process guard when no LockProvider is registered.
44
+ ScheduledTask.lockManager = (this.app.container.tryMake("lock") as LockManager | null) ?? null;
45
+
46
+ const runner = this.app.container.tryMake("commands");
47
+ if (!runner) return;
48
+ runner.registerLazy("schedule:list", () =>
49
+ import("../commands/ScheduleListCommand.ts").then((m) => m.ScheduleListCommand),
50
+ );
51
+ }
52
+
53
+ override async onStarted(): Promise<void> {
54
+ const scheduler = this.app.container.makeSync("scheduler") as SchedulerManager;
55
+ scheduler.start();
56
+ }
57
+
58
+ override async onStopped(): Promise<void> {
59
+ this._disposeObservability?.();
60
+ this._disposeObservability = undefined;
61
+
62
+ const scheduler = this.app.container.tryMake("scheduler") as SchedulerManager | undefined;
63
+ scheduler?.stop();
64
+ }
65
+ }