@smitejs/jobs 2.0.0-SNAPSHOT

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,196 @@
1
+ const MINUTE_MS = 60_000;
2
+ const CRON_FIELDS = 5;
3
+ const LOOK_AHEAD_MS = 366 * 24 * 60 * MINUTE_MS;
4
+
5
+ /**
6
+ * A job run schedule: either a POSIX cron expression (minute hour day-of-month
7
+ * month day-of-week; `0` and `7` both mean Sunday) or a fixed interval in
8
+ * milliseconds.
9
+ *
10
+ * @group Types
11
+ */
12
+ export type JobSchedule =
13
+ | { readonly kind: "cron"; readonly expression: string }
14
+ | { readonly kind: "interval"; readonly milliseconds: number };
15
+
16
+ /**
17
+ * Builds a cron schedule from a 5-field POSIX expression. Every field accepts
18
+ * a bare value (`*`, `n`), a range (`a-b`), a range with a step (`a-b/n`), a
19
+ * wildcard with a step (for example `*` followed by `/n`), or a
20
+ * comma-separated list of any of those.
21
+ *
22
+ * @group Builders
23
+ * @example Schedule a job on a cron expression
24
+ */
25
+ export const cron = (expression: string): JobSchedule => {
26
+ compileCron(expression);
27
+ return { kind: "cron", expression };
28
+ };
29
+
30
+ /**
31
+ * Builds a fixed-interval schedule from a duration in milliseconds.
32
+ *
33
+ * @group Builders
34
+ */
35
+ export const interval = (milliseconds: number): JobSchedule => ({
36
+ kind: "interval",
37
+ milliseconds,
38
+ });
39
+
40
+ type Range = { readonly min: number; readonly max: number };
41
+
42
+ /** Predicate deciding whether a single field value is allowed. */
43
+ export type FieldMatcher = (value: number) => boolean;
44
+
45
+ /** A compiled cron expression: five field matchers plus wildcard flags. */
46
+ export interface CompiledCron {
47
+ readonly minute: FieldMatcher;
48
+ readonly hour: FieldMatcher;
49
+ readonly dayOfMonth: FieldMatcher;
50
+ readonly month: FieldMatcher;
51
+ readonly dayOfWeek: FieldMatcher;
52
+ readonly dayOfMonthIsStar: boolean;
53
+ readonly dayOfWeekIsStar: boolean;
54
+ }
55
+
56
+ const compileToken = (token: string, { min, max }: Range): FieldMatcher => {
57
+ const stepMatch = /^(.+?)\/(\d+)$/u.exec(token);
58
+ const step = stepMatch === null ? 1 : Number(stepMatch[2]);
59
+ const base = stepMatch === null ? token : (stepMatch[1] ?? "*");
60
+
61
+ let low: number;
62
+ let high: number;
63
+ if (base === "*") {
64
+ low = min;
65
+ high = max;
66
+ } else {
67
+ const range = /^(\d+)(?:-(\d+))?$/u.exec(base);
68
+ if (range === null) {
69
+ throw new Error(`Invalid cron token '${token}'.`);
70
+ }
71
+ low = Number(range[1]);
72
+ high = range[2] === undefined ? low : Number(range[2]);
73
+ if (low < min || high > max || low > high) {
74
+ throw new Error(`Cron token '${token}' is out of range [${min}–${max}].`);
75
+ }
76
+ }
77
+
78
+ return (value: number): boolean =>
79
+ value >= low && value <= high && (value - low) % step === 0;
80
+ };
81
+
82
+ const compileField = (field: string, range: Range): [FieldMatcher, boolean] => {
83
+ const tokens = field.split(",").map((token) => token.trim());
84
+ if (tokens.length === 0 || tokens.some((token) => token.length === 0)) {
85
+ throw new Error(`Empty cron field '${field}'.`);
86
+ }
87
+ const matchers = tokens.map((token) => compileToken(token, range));
88
+ return [
89
+ (value: number): boolean => matchers.some((matcher) => matcher(value)),
90
+ tokens.every((token) => token === "*"),
91
+ ];
92
+ };
93
+
94
+ /**
95
+ * Parses a 5-field POSIX cron expression into {@link CompiledCron}.
96
+ *
97
+ * @group Internals
98
+ */
99
+ export function compileCron(expr: string): CompiledCron {
100
+ const fields = expr.trim().split(/\s+/u);
101
+ if (fields.length !== CRON_FIELDS) {
102
+ throw new Error(
103
+ `Cron expression '${expr}' must have exactly ${CRON_FIELDS} fields.`,
104
+ );
105
+ }
106
+ const [min, hour, dayOfMonth, month, dayOfWeek] = fields;
107
+ if (
108
+ min === undefined ||
109
+ hour === undefined ||
110
+ dayOfMonth === undefined ||
111
+ month === undefined ||
112
+ dayOfWeek === undefined
113
+ ) {
114
+ throw new Error(`Invalid cron expression '${expr}'.`);
115
+ }
116
+
117
+ const [minute] = compileField(min, { min: 0, max: 59 });
118
+ const [hourMatcher] = compileField(hour, { min: 0, max: 23 });
119
+ const [domMatcher, domStar] = compileField(dayOfMonth, { min: 1, max: 31 });
120
+ const [monthMatcher] = compileField(month, { min: 1, max: 12 });
121
+ const [dowMatcher, dowStar] = compileField(dayOfWeek, { min: 0, max: 7 });
122
+
123
+ return {
124
+ minute,
125
+ hour: hourMatcher,
126
+ dayOfMonth: domMatcher,
127
+ month: monthMatcher,
128
+ dayOfWeek: (value: number): boolean =>
129
+ dowMatcher(value) || (value === 0 && dowMatcher(7)),
130
+ dayOfMonthIsStar: domStar,
131
+ dayOfWeekIsStar: dowStar,
132
+ };
133
+ }
134
+
135
+ const dayAllows = (
136
+ dom: FieldMatcher,
137
+ dow: FieldMatcher,
138
+ domStar: boolean,
139
+ dowStar: boolean,
140
+ date: number,
141
+ weekday: number,
142
+ ): boolean => {
143
+ if (!domStar && !dowStar) {
144
+ return dom(date) || dow(weekday);
145
+ }
146
+ if (dowStar) {
147
+ return dom(date);
148
+ }
149
+ return dow(weekday);
150
+ };
151
+
152
+ /**
153
+ * Computes the next moment `schedule` fires at or after `from`. Cron fires on
154
+ * the first matching minute boundary; interval schedules fire `milliseconds`
155
+ * after `from`. Returns `null` when no cron fire is within the look-ahead
156
+ * horizon (one year).
157
+ *
158
+ * @group Executor
159
+ * @example Compute the next cron fire
160
+ */
161
+ export function nextFire(schedule: JobSchedule, from: Date): Date | null {
162
+ if (schedule.kind === "interval") {
163
+ return new Date(from.getTime() + schedule.milliseconds);
164
+ }
165
+
166
+ const compiled = compileCron(schedule.expression);
167
+ const start = new Date(from);
168
+ start.setUTCSeconds(0, 0);
169
+ start.setUTCMinutes(start.getUTCMinutes() + 1);
170
+
171
+ const deadline = start.getTime() + LOOK_AHEAD_MS;
172
+ for (
173
+ let cursor = start;
174
+ cursor.getTime() <= deadline;
175
+ cursor = new Date(cursor.getTime() + MINUTE_MS)
176
+ ) {
177
+ const minuteMatch = compiled.minute(cursor.getUTCMinutes());
178
+ const hourMatch = compiled.hour(cursor.getUTCHours());
179
+ const monthMatch = compiled.month(cursor.getUTCMonth() + 1);
180
+ if (!minuteMatch || !hourMatch || !monthMatch) {
181
+ continue;
182
+ }
183
+ const dayMatch = dayAllows(
184
+ compiled.dayOfMonth,
185
+ compiled.dayOfWeek,
186
+ compiled.dayOfMonthIsStar,
187
+ compiled.dayOfWeekIsStar,
188
+ cursor.getUTCDate(),
189
+ cursor.getUTCDay(),
190
+ );
191
+ if (dayMatch) {
192
+ return cursor;
193
+ }
194
+ }
195
+ return null;
196
+ }
@@ -0,0 +1,127 @@
1
+ import { finalizeDescriptor } from "@smitejs/core";
2
+ import type { AppDescriptor } from "@smitejs/core";
3
+ import type { EmptySignal } from "@smitejs/handlers";
4
+ import { fire } from "@smitejs/handlers";
5
+ import { jobsOf } from "./collector.js";
6
+ import type { CollectedJob } from "./collector.js";
7
+ import { nextFire } from "./schedule.js";
8
+
9
+ /** A timer id from `setTimeout` or `setInterval`. */
10
+ type TimerHandle = ReturnType<typeof setTimeout>;
11
+
12
+ /**
13
+ * Options for {@link scheduler}.
14
+ *
15
+ * @group Executor
16
+ */
17
+ export interface SchedulerOptions {
18
+ /** Invoked before each job runs, with the job id and fire time. */
19
+ readonly onRun?: (entry: {
20
+ readonly id: string;
21
+ readonly at: number;
22
+ }) => void;
23
+ /** Invoked when a job's run function throws or rejects. */
24
+ readonly onError?: (entry: {
25
+ readonly id: string;
26
+ readonly error: unknown;
27
+ }) => void;
28
+ /** Also arms the schedule clock when `start()` runs. */
29
+ readonly start?: boolean;
30
+ }
31
+
32
+ /**
33
+ * A runtime handle returned by {@link scheduler}: the collected jobs, plus
34
+ * `start` (fire everything once) and `tick`/`stop` to drive the fire clock.
35
+ *
36
+ * @group Executor
37
+ */
38
+ export interface JobScheduler {
39
+ readonly jobs: readonly CollectedJob[];
40
+ /** Runs every job once immediately; with `{ start: true }` arms the clock. */
41
+ readonly start: () => Promise<void>;
42
+ /** Arms each job's cron/interval timer and returns a one-shot `stop`. */
43
+ readonly tick: () => () => void;
44
+ /** Clears every armed timer. */
45
+ readonly stop: () => void;
46
+ }
47
+
48
+ const signalOf = (job: { readonly id: string }): EmptySignal => fire(job.id);
49
+
50
+ /**
51
+ * Turns an app into a runtime job scheduler. Walks the app's `jobs.job` IR
52
+ * tree via child refs — never the global registry — so it keeps working in
53
+ * production bundles where collect mode is folded out. `start()` fires every
54
+ * job once; `tick()` arms the cron/interval timers and returns a `stop` for
55
+ * shutdown; `{ start: true }` arms automatically. `onRun`/`onError` observe
56
+ * each fire.
57
+ *
58
+ * @group Executor
59
+ * @example Schedule an app's jobs
60
+ */
61
+ export function scheduler(
62
+ app: AppDescriptor,
63
+ options: SchedulerOptions = {},
64
+ ): JobScheduler {
65
+ finalizeDescriptor(app);
66
+
67
+ const jobs = jobsOf(app);
68
+ const timers = new Set<TimerHandle>();
69
+
70
+ const runJob = async (job: CollectedJob): Promise<void> => {
71
+ options.onRun?.({ id: job.id, at: Date.now() });
72
+ try {
73
+ await Promise.resolve(job.run(signalOf(job)));
74
+ } catch (error) {
75
+ options.onError?.({ id: job.id, error });
76
+ }
77
+ };
78
+
79
+ const armJob = (job: CollectedJob): void => {
80
+ if (job.schedule.kind === "interval") {
81
+ const timer = setInterval(() => {
82
+ void runJob(job);
83
+ }, job.schedule.milliseconds);
84
+ timers.add(timer);
85
+ return;
86
+ }
87
+
88
+ const armNext = (): void => {
89
+ const next = nextFire(job.schedule, new Date());
90
+ if (next === null) return;
91
+ const timer = setTimeout(
92
+ () => {
93
+ timers.delete(timer);
94
+ void runJob(job);
95
+ armNext();
96
+ },
97
+ Math.max(0, next.getTime() - Date.now()),
98
+ );
99
+ timers.add(timer);
100
+ };
101
+ armNext();
102
+ };
103
+
104
+ const stop = (): void => {
105
+ for (const timer of timers) clearTimer(timer);
106
+ timers.clear();
107
+ };
108
+
109
+ return {
110
+ jobs,
111
+ start: async () => {
112
+ for (const job of jobs) {
113
+ await runJob(job);
114
+ }
115
+ if (options.start === true) {
116
+ for (const job of jobs) armJob(job);
117
+ }
118
+ },
119
+ tick: () => {
120
+ for (const job of jobs) armJob(job);
121
+ return stop;
122
+ },
123
+ stop,
124
+ };
125
+ }
126
+
127
+ const clearTimer = (timer: TimerHandle): void => clearInterval(timer);
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "tsBuildInfoFile": "dist/.tsbuildinfo"
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "exclude": ["src/**/*.test.ts"],
10
+ "references": [{ "path": "../core" }, { "path": "../handlers" }]
11
+ }