@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.
- package/dist/.tsbuildinfo +1 -0
- package/dist/collector.d.ts +33 -0
- package/dist/collector.d.ts.map +1 -0
- package/dist/collector.js +29 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/job.d.ts +89 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +47 -0
- package/dist/schedule.d.ts +67 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +141 -0
- package/dist/scheduler.d.ts +53 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +79 -0
- package/package.json +34 -0
- package/src/collector.ts +44 -0
- package/src/docs.test.ts +65 -0
- package/src/index.test.ts +176 -0
- package/src/index.ts +36 -0
- package/src/job.ts +108 -0
- package/src/schedule.ts +196 -0
- package/src/scheduler.ts +127 -0
- package/tsconfig.json +11 -0
package/src/schedule.ts
ADDED
|
@@ -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
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -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
|
+
}
|