@ecosy/schedule 0.1.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/README.md +1 -0
- package/package.json +41 -0
- package/src/cron.ts +368 -0
- package/src/hook/index.ts +101 -0
- package/src/index.ts +6 -0
- package/src/registry.ts +100 -0
- package/src/runner.ts +150 -0
- package/src/schedule.ts +387 -0
- package/src/source/index.ts +126 -0
- package/src/task.ts +124 -0
- package/src/types.ts +147 -0
- package/tsconfig.json +23 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# ecosy-schedule
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ecosy/schedule",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cron scheduling with pluggable sources, coordination and reporting — no dependencies, runs anywhere Node does",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"source": "./src/index.ts",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./source": {
|
|
14
|
+
"source": "./src/source/index.ts",
|
|
15
|
+
"types": "./dist/source/index.d.ts",
|
|
16
|
+
"default": "./src/source/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"./hook": {
|
|
19
|
+
"source": "./src/hook/index.ts",
|
|
20
|
+
"types": "./dist/hook/index.d.ts",
|
|
21
|
+
"default": "./src/hook/index.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc",
|
|
27
|
+
"prepublishOnly": "yarn build"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/material-atomic/ecosy-schedule.git"
|
|
35
|
+
},
|
|
36
|
+
"author": "material-atomic",
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^26.2.0",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron expression parsing and next-fire calculation.
|
|
3
|
+
*
|
|
4
|
+
* Six fields, seconds first:
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* ┌─────── second 0-59
|
|
8
|
+
* │ ┌───── minute 0-59
|
|
9
|
+
* │ │ ┌─── hour 0-23
|
|
10
|
+
* │ │ │ ┌─ day of month 1-31
|
|
11
|
+
* │ │ │ │ ┌ month 1-12 or JAN-DEC
|
|
12
|
+
* │ │ │ │ │ ┌ day of week 0-6 or SUN-SAT (7 also means Sunday)
|
|
13
|
+
* * * * * * *
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Five fields are accepted too and read as standard crontab — seconds become
|
|
17
|
+
* 0. That is not leniency for its own sake: an expression copied from a
|
|
18
|
+
* crontab into a six-field parser shifts every value one column left and runs
|
|
19
|
+
* at the wrong time without erroring. Counting the fields removes the trap.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface CronField {
|
|
23
|
+
/** Sorted, de-duplicated values this field matches. */
|
|
24
|
+
values: number[];
|
|
25
|
+
/** `*` — true when the field constrains nothing. Needed for the dom/dow rule. */
|
|
26
|
+
wildcard: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CronExpression {
|
|
30
|
+
second: CronField;
|
|
31
|
+
minute: CronField;
|
|
32
|
+
hour: CronField;
|
|
33
|
+
dayOfMonth: CronField;
|
|
34
|
+
month: CronField;
|
|
35
|
+
dayOfWeek: CronField;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
39
|
+
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
40
|
+
|
|
41
|
+
interface FieldSpec {
|
|
42
|
+
name: string;
|
|
43
|
+
min: number;
|
|
44
|
+
max: number;
|
|
45
|
+
names?: string[];
|
|
46
|
+
/** Offset to add to a name index, e.g. months are 1-based. */
|
|
47
|
+
nameBase?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SPECS: FieldSpec[] = [
|
|
51
|
+
{ name: "second", min: 0, max: 59 },
|
|
52
|
+
{ name: "minute", min: 0, max: 59 },
|
|
53
|
+
{ name: "hour", min: 0, max: 23 },
|
|
54
|
+
{ name: "day of month", min: 1, max: 31 },
|
|
55
|
+
{ name: "month", min: 1, max: 12, names: MONTHS, nameBase: 1 },
|
|
56
|
+
{ name: "day of week", min: 0, max: 6, names: DAYS, nameBase: 0 },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function fail(expression: string, message: string): never {
|
|
60
|
+
throw new Error(`Cron: ${message} in "${expression}"`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toNumber(token: string, spec: FieldSpec, expression: string): number {
|
|
64
|
+
const named = spec.names?.indexOf(token.toLowerCase());
|
|
65
|
+
|
|
66
|
+
if (named !== undefined && named >= 0) {
|
|
67
|
+
return named + (spec.nameBase ?? 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!/^\d+$/.test(token)) {
|
|
71
|
+
fail(expression, `"${token}" is not a valid ${spec.name}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const value = Number(token);
|
|
75
|
+
|
|
76
|
+
// 7 is Sunday as well as 0 — both spellings are in the wild, and rejecting
|
|
77
|
+
// one of them breaks expressions people copy from working systems.
|
|
78
|
+
if (spec.name === "day of week" && value === 7) return 0;
|
|
79
|
+
|
|
80
|
+
if (value < spec.min || value > spec.max) {
|
|
81
|
+
fail(expression, `${spec.name} ${value} is outside ${spec.min}-${spec.max}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseField(raw: string, spec: FieldSpec, expression: string): CronField {
|
|
88
|
+
// `?` means "no specific value" in Quartz. Treated as `*`, which is what it
|
|
89
|
+
// amounts to once the dom/dow rule below is applied.
|
|
90
|
+
const field = raw === "?" ? "*" : raw;
|
|
91
|
+
const wildcard = field === "*";
|
|
92
|
+
const values = new Set<number>();
|
|
93
|
+
|
|
94
|
+
for (const part of field.split(",")) {
|
|
95
|
+
const [range, stepRaw] = part.split("/");
|
|
96
|
+
|
|
97
|
+
if (stepRaw !== undefined && !/^\d+$/.test(stepRaw)) {
|
|
98
|
+
fail(expression, `step "${stepRaw}" is not a number`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const step = stepRaw === undefined ? 1 : Number(stepRaw);
|
|
102
|
+
if (step < 1) fail(expression, `step must be at least 1`);
|
|
103
|
+
|
|
104
|
+
let from: number;
|
|
105
|
+
let to: number;
|
|
106
|
+
|
|
107
|
+
if (range === "*") {
|
|
108
|
+
from = spec.min;
|
|
109
|
+
to = spec.max;
|
|
110
|
+
} else if (range.includes("-")) {
|
|
111
|
+
const [a, b] = range.split("-");
|
|
112
|
+
from = toNumber(a, spec, expression);
|
|
113
|
+
to = toNumber(b, spec, expression);
|
|
114
|
+
} else {
|
|
115
|
+
from = toNumber(range, spec, expression);
|
|
116
|
+
// A bare value with a step means "from here to the end", e.g. `5/15`.
|
|
117
|
+
to = stepRaw === undefined ? from : spec.max;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (from > to) fail(expression, `range ${from}-${to} runs backwards`);
|
|
121
|
+
|
|
122
|
+
for (let value = from; value <= to; value += step) values.add(value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (values.size === 0) fail(expression, `field "${raw}" matches nothing`);
|
|
126
|
+
|
|
127
|
+
return { values: [...values].sort((a, b) => a - b), wildcard };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseCron(expression: string): CronExpression {
|
|
131
|
+
const fields = expression.trim().split(/\s+/);
|
|
132
|
+
|
|
133
|
+
// Five fields is standard crontab: prepend a zero second rather than
|
|
134
|
+
// silently reading minute-as-second.
|
|
135
|
+
const parts = fields.length === 5 ? ["0", ...fields] : fields;
|
|
136
|
+
|
|
137
|
+
if (parts.length !== 6) {
|
|
138
|
+
fail(expression, `expected 5 or 6 fields, got ${fields.length}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const [second, minute, hour, dayOfMonth, month, dayOfWeek] = parts.map((part, i) =>
|
|
142
|
+
parseField(part, SPECS[i], expression),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return { second, minute, hour, dayOfMonth, month, dayOfWeek };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ------------------------------------------------------ next fire */
|
|
149
|
+
|
|
150
|
+
interface Wall {
|
|
151
|
+
year: number;
|
|
152
|
+
month: number;
|
|
153
|
+
day: number;
|
|
154
|
+
hour: number;
|
|
155
|
+
minute: number;
|
|
156
|
+
second: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Wall-clock fields of an instant in a zone.
|
|
161
|
+
*
|
|
162
|
+
* `hourCycle: "h23"` matters: with plain `hour12: false` some locales render
|
|
163
|
+
* midnight as 24, which then reads as an invalid hour.
|
|
164
|
+
*/
|
|
165
|
+
function partsInZone(instant: Date, timeZone: string): Wall {
|
|
166
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
167
|
+
timeZone,
|
|
168
|
+
hourCycle: "h23",
|
|
169
|
+
year: "numeric",
|
|
170
|
+
month: "2-digit",
|
|
171
|
+
day: "2-digit",
|
|
172
|
+
hour: "2-digit",
|
|
173
|
+
minute: "2-digit",
|
|
174
|
+
second: "2-digit",
|
|
175
|
+
}).formatToParts(instant);
|
|
176
|
+
|
|
177
|
+
const read = (type: string) => Number(parts.find((p) => p.type === type)!.value);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
year: read("year"),
|
|
181
|
+
month: read("month"),
|
|
182
|
+
day: read("day"),
|
|
183
|
+
hour: read("hour"),
|
|
184
|
+
minute: read("minute"),
|
|
185
|
+
second: read("second"),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The instant at which a zone's wall clock reads `wall`.
|
|
191
|
+
*
|
|
192
|
+
* Returns null when no such instant exists — the hour skipped by a
|
|
193
|
+
* spring-forward transition. The caller treats that as "this occurrence does
|
|
194
|
+
* not happen" and looks for the next one, which is what every cron does with a
|
|
195
|
+
* time that the calendar simply never reaches.
|
|
196
|
+
*
|
|
197
|
+
* Ambiguous times, the hour repeated by a fall-back transition, resolve to the
|
|
198
|
+
* first occurrence. Since the search always moves forward from the previous
|
|
199
|
+
* fire, the second occurrence is never selected and the job runs once.
|
|
200
|
+
*/
|
|
201
|
+
function instantFromWall(wall: Wall, timeZone: string): Date | null {
|
|
202
|
+
const asIfUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
|
|
203
|
+
|
|
204
|
+
// Offsets can shift between the guess and the answer, so iterate: each pass
|
|
205
|
+
// measures the zone's offset at the current guess and corrects. Two passes
|
|
206
|
+
// suffice everywhere; a third is cheap insurance against odd historical
|
|
207
|
+
// zones.
|
|
208
|
+
let guess = asIfUtc;
|
|
209
|
+
|
|
210
|
+
for (let i = 0; i < 3; i++) {
|
|
211
|
+
const seen = partsInZone(new Date(guess), timeZone);
|
|
212
|
+
const seenAsUtc = Date.UTC(seen.year, seen.month - 1, seen.day, seen.hour, seen.minute, seen.second);
|
|
213
|
+
const corrected = asIfUtc - (seenAsUtc - guess);
|
|
214
|
+
|
|
215
|
+
if (corrected === guess) break;
|
|
216
|
+
guess = corrected;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const check = partsInZone(new Date(guess), timeZone);
|
|
220
|
+
const exists =
|
|
221
|
+
check.year === wall.year && check.month === wall.month && check.day === wall.day &&
|
|
222
|
+
check.hour === wall.hour && check.minute === wall.minute && check.second === wall.second;
|
|
223
|
+
|
|
224
|
+
return exists ? new Date(guess) : null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Weekday of a calendar date. Independent of zone: a date has one weekday. */
|
|
228
|
+
function weekdayOf(year: number, month: number, day: number): number {
|
|
229
|
+
return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function daysInMonth(year: number, month: number): number {
|
|
233
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function nextValue(field: CronField, from: number): number | null {
|
|
237
|
+
for (const value of field.values) if (value >= from) return value;
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether a date matches the day fields.
|
|
243
|
+
*
|
|
244
|
+
* The rule is inherited from Vixie cron and surprises nearly everyone: when
|
|
245
|
+
* *both* day-of-month and day-of-week are restricted, a date matches if
|
|
246
|
+
* *either* does — not both. `0 0 0 1 * 1` fires on the 1st of every month and
|
|
247
|
+
* on every Monday. When one of them is `*`, only the other constrains.
|
|
248
|
+
*/
|
|
249
|
+
function matchesDay(expr: CronExpression, year: number, month: number, day: number): boolean {
|
|
250
|
+
const domMatch = expr.dayOfMonth.values.includes(day);
|
|
251
|
+
const dowMatch = expr.dayOfWeek.values.includes(weekdayOf(year, month, day));
|
|
252
|
+
|
|
253
|
+
if (expr.dayOfMonth.wildcard && expr.dayOfWeek.wildcard) return true;
|
|
254
|
+
if (expr.dayOfMonth.wildcard) return dowMatch;
|
|
255
|
+
if (expr.dayOfWeek.wildcard) return domMatch;
|
|
256
|
+
|
|
257
|
+
return domMatch || dowMatch;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Bound on the search, in candidate days. Beyond five years an expression is unsatisfiable. */
|
|
261
|
+
const MAX_DAYS = 366 * 5;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The first instant strictly after `from` that matches `expression` in `timeZone`.
|
|
265
|
+
*
|
|
266
|
+
* Returns null when the expression can never match — `0 0 0 30 2 *` asks for
|
|
267
|
+
* February 30th. Returning null rather than looping forever means a bad row in
|
|
268
|
+
* a cron table is reported, not a hung process.
|
|
269
|
+
*/
|
|
270
|
+
export function nextFire(
|
|
271
|
+
expr: CronExpression,
|
|
272
|
+
from: Date,
|
|
273
|
+
timeZone = "UTC",
|
|
274
|
+
): Date | null {
|
|
275
|
+
const start = partsInZone(new Date(from.getTime() + 1000), timeZone);
|
|
276
|
+
|
|
277
|
+
let { year, month, day, hour, minute, second } = start;
|
|
278
|
+
let daysScanned = 0;
|
|
279
|
+
|
|
280
|
+
while (daysScanned <= MAX_DAYS) {
|
|
281
|
+
// ── month
|
|
282
|
+
const nextMonth = nextValue(expr.month, month);
|
|
283
|
+
if (nextMonth === null) {
|
|
284
|
+
year++;
|
|
285
|
+
month = expr.month.values[0];
|
|
286
|
+
day = 1;
|
|
287
|
+
hour = minute = second = 0;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (nextMonth !== month) {
|
|
291
|
+
month = nextMonth;
|
|
292
|
+
day = 1;
|
|
293
|
+
hour = minute = second = 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── day
|
|
297
|
+
if (day > daysInMonth(year, month) || !matchesDay(expr, year, month, day)) {
|
|
298
|
+
day++;
|
|
299
|
+
hour = minute = second = 0;
|
|
300
|
+
daysScanned++;
|
|
301
|
+
|
|
302
|
+
if (day > daysInMonth(year, month)) {
|
|
303
|
+
day = 1;
|
|
304
|
+
month++;
|
|
305
|
+
if (month > 12) {
|
|
306
|
+
month = 1;
|
|
307
|
+
year++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── hour
|
|
314
|
+
const nextHour = nextValue(expr.hour, hour);
|
|
315
|
+
if (nextHour === null) {
|
|
316
|
+
day++;
|
|
317
|
+
hour = minute = second = 0;
|
|
318
|
+
daysScanned++;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (nextHour !== hour) {
|
|
322
|
+
hour = nextHour;
|
|
323
|
+
minute = second = 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── minute
|
|
327
|
+
const nextMinute = nextValue(expr.minute, minute);
|
|
328
|
+
if (nextMinute === null) {
|
|
329
|
+
hour++;
|
|
330
|
+
minute = second = 0;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (nextMinute !== minute) {
|
|
334
|
+
minute = nextMinute;
|
|
335
|
+
second = 0;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── second
|
|
339
|
+
const nextSecond = nextValue(expr.second, second);
|
|
340
|
+
if (nextSecond === null) {
|
|
341
|
+
minute++;
|
|
342
|
+
second = 0;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
second = nextSecond;
|
|
346
|
+
|
|
347
|
+
const instant = instantFromWall({ year, month, day, hour, minute, second }, timeZone);
|
|
348
|
+
|
|
349
|
+
// A wall time inside a spring-forward gap never happens. Step past it and
|
|
350
|
+
// keep looking rather than reporting a time the clock will not reach.
|
|
351
|
+
if (instant === null) {
|
|
352
|
+
second++;
|
|
353
|
+
if (second > 59) {
|
|
354
|
+
second = 0;
|
|
355
|
+
minute++;
|
|
356
|
+
if (minute > 59) {
|
|
357
|
+
minute = 0;
|
|
358
|
+
hour++;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return instant;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ClassType, Hook as HookPort, Promisable, TaskEvent } from "../types";
|
|
2
|
+
|
|
3
|
+
/** Milliseconds a hook gets before it is abandoned. Separate from the task's own timeout. */
|
|
4
|
+
const HOOK_TIMEOUT = 10_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Builds a hook class that writes each outcome as a log line.
|
|
8
|
+
*
|
|
9
|
+
* A factory returning a class, not an object: the scheduler constructs whatever
|
|
10
|
+
* it is handed, so everything given to `.hook()` arrives in the same shape and
|
|
11
|
+
* nothing has to be told apart at runtime.
|
|
12
|
+
*
|
|
13
|
+
* Takes anything console-shaped, which `console` itself already is, so it costs
|
|
14
|
+
* no dependency and works before real logging is wired up.
|
|
15
|
+
*/
|
|
16
|
+
export function LoggerHook(
|
|
17
|
+
logger: { info(...a: unknown[]): void; error(...a: unknown[]): void } = console,
|
|
18
|
+
): ClassType<HookPort> {
|
|
19
|
+
return class implements HookPort {
|
|
20
|
+
notify(event: TaskEvent) {
|
|
21
|
+
const took = `${event.durationMs}ms`;
|
|
22
|
+
|
|
23
|
+
if (event.ok) {
|
|
24
|
+
logger.info(`[schedule] ${event.key} ok in ${took}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
logger.error(
|
|
29
|
+
`[schedule] ${event.key} failed (${event.reason}) after ${took}` +
|
|
30
|
+
`${event.attempt > 1 ? ` on attempt ${event.attempt}` : ""}: ${event.detail ?? ""}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runs one hook under a deadline, swallowing whatever it does.
|
|
38
|
+
*
|
|
39
|
+
* Takes a function rather than a promise on purpose. Calling `notify()` at the
|
|
40
|
+
* call site and passing the result would leave a *synchronous* throw outside
|
|
41
|
+
* this handler entirely — it escapes before there is anything to catch it, and
|
|
42
|
+
* one badly written hook takes down every other hook in the same batch.
|
|
43
|
+
* Invoking inside `.then` turns that throw into a rejection like any other.
|
|
44
|
+
*/
|
|
45
|
+
function deadline(work: () => Promisable<void>, ms: number, label: string): Promise<void> {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
console.warn(`[schedule] hook ${label} exceeded ${ms}ms — abandoned`);
|
|
49
|
+
resolve();
|
|
50
|
+
}, ms);
|
|
51
|
+
|
|
52
|
+
Promise.resolve()
|
|
53
|
+
.then(work)
|
|
54
|
+
.then(
|
|
55
|
+
() => { clearTimeout(timer); resolve(); },
|
|
56
|
+
(error) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
console.warn(`[schedule] hook ${label} threw:`, error);
|
|
59
|
+
resolve();
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Marks a composite so nesting one inside another flattens instead of nesting. */
|
|
66
|
+
const PARTS = Symbol.for("@ecosy/schedule:hook-parts");
|
|
67
|
+
|
|
68
|
+
interface HookFactory {
|
|
69
|
+
/**
|
|
70
|
+
* Fans one event out to several hooks, as a single hook class.
|
|
71
|
+
*
|
|
72
|
+
* Concurrently, and each isolated: a Telegram call that fails must not stop
|
|
73
|
+
* the log line from being written, and a slow one must not hold the
|
|
74
|
+
* scheduler — the task has already finished, reporting is separate work.
|
|
75
|
+
*
|
|
76
|
+
* Flattens, so `combine(a, combine(b, c))` and `combine(a, b, c)` behave the
|
|
77
|
+
* same. With no arguments it is a valid no-op, which makes it a usable
|
|
78
|
+
* default rather than something callers must guard against.
|
|
79
|
+
*/
|
|
80
|
+
combine(...hooks: ClassType<HookPort>[]): ClassType<HookPort>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const Hook: HookFactory = {
|
|
84
|
+
combine(...hooks: ClassType<HookPort>[]): ClassType<HookPort> {
|
|
85
|
+
const flat = hooks.flatMap(
|
|
86
|
+
(hook) => (hook as { [PARTS]?: ClassType<HookPort>[] })[PARTS] ?? [hook],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return class Combined implements HookPort {
|
|
90
|
+
static readonly [PARTS] = flat;
|
|
91
|
+
|
|
92
|
+
private readonly parts = flat.map((Hook) => new Hook());
|
|
93
|
+
|
|
94
|
+
async notify(event: TaskEvent) {
|
|
95
|
+
await Promise.all(
|
|
96
|
+
this.parts.map((hook, i) => deadline(() => hook.notify(event), HOOK_TIMEOUT, `#${i}`)),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
};
|
package/src/index.ts
ADDED
package/src/registry.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ClassType, InjectMap, Injected, Promisable } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Handlers, held by name.
|
|
5
|
+
*
|
|
6
|
+
* A source entry is text, so it cannot carry a function — only a key that
|
|
7
|
+
* points at one. This is the other half of that: the code declares handlers
|
|
8
|
+
* under names, and the source names them.
|
|
9
|
+
*
|
|
10
|
+
* An entry is a class, not an object, because the scheduler constructs it —
|
|
11
|
+
* once per run, so a handler never holds a connection open between fires. The
|
|
12
|
+
* key lives on the static side because the lookup happens before anything is
|
|
13
|
+
* built.
|
|
14
|
+
*/
|
|
15
|
+
export type HandlerFn<Context = unknown> = (context: Context) => Promisable<unknown>;
|
|
16
|
+
|
|
17
|
+
export interface RegistryEntry<Context = unknown> {
|
|
18
|
+
run(context: Context): Promisable<unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A handler class: `key` to find it by, `run` once it exists. */
|
|
22
|
+
export interface RegistryEntryClass<Context = unknown> extends ClassType<RegistryEntry<Context>> {
|
|
23
|
+
readonly key: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IRegistry<Context = unknown> {
|
|
27
|
+
add(entry: RegistryEntryClass<Context>): IRegistry<Context>;
|
|
28
|
+
/** The handler under `key`, or undefined — which is what `notFound` reports on. */
|
|
29
|
+
get(key: string): RegistryEntryClass<Context> | undefined;
|
|
30
|
+
keys(): string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The registry itself stays a plain collection.
|
|
35
|
+
*
|
|
36
|
+
* Nothing ever constructs it — it is read, not built — so making it a class
|
|
37
|
+
* would be ceremony without a job. The rule is about what the scheduler
|
|
38
|
+
* instantiates, and that is the entries, not the box holding them.
|
|
39
|
+
*/
|
|
40
|
+
function build<Context>(entries: ReadonlyMap<string, RegistryEntryClass<Context>>): IRegistry<Context> {
|
|
41
|
+
return {
|
|
42
|
+
add(entry) {
|
|
43
|
+
// Last registration wins rather than throwing: a hot reload re-runs the
|
|
44
|
+
// declaration, and refusing the second one would leave the first, stale
|
|
45
|
+
// closure in place — the opposite of what an edit is asking for.
|
|
46
|
+
const next = new Map(entries);
|
|
47
|
+
next.set(entry.key, entry);
|
|
48
|
+
return build(next);
|
|
49
|
+
},
|
|
50
|
+
get: (key) => entries.get(key),
|
|
51
|
+
keys: () => [...entries.keys()],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RegistryFactory {
|
|
56
|
+
/** A handler with no dependencies. */
|
|
57
|
+
<Context = unknown>(key: string, handler: HandlerFn<Context>): RegistryEntryClass<Context>;
|
|
58
|
+
/** A handler whose context carries the given tokens, constructed per run. */
|
|
59
|
+
<Injects extends InjectMap, Context = unknown>(
|
|
60
|
+
key: string,
|
|
61
|
+
injects: Injects,
|
|
62
|
+
handler: HandlerFn<Injected<Context, Injects>>,
|
|
63
|
+
): RegistryEntryClass<Context>;
|
|
64
|
+
/** An empty registry to chain `.add()` onto. */
|
|
65
|
+
empty<Context = unknown>(): IRegistry<Context>;
|
|
66
|
+
add<Context = unknown>(entry: RegistryEntryClass<Context>): IRegistry<Context>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const RegistryImpl = function (
|
|
70
|
+
key: string,
|
|
71
|
+
second: InjectMap | HandlerFn<never>,
|
|
72
|
+
third?: HandlerFn<never>,
|
|
73
|
+
): RegistryEntryClass<never> {
|
|
74
|
+
const injects = typeof second === "function" ? {} : second;
|
|
75
|
+
const handler = (typeof second === "function" ? second : third) as HandlerFn<never>;
|
|
76
|
+
|
|
77
|
+
if (typeof handler !== "function") {
|
|
78
|
+
throw new Error(`Registry: handler for "${key}" is missing`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return class Entry {
|
|
82
|
+
static readonly key = key;
|
|
83
|
+
|
|
84
|
+
run(context: never) {
|
|
85
|
+
// Tokens are constructed here, per run, so a handler never holds a
|
|
86
|
+
// connection open between fires.
|
|
87
|
+
const scoped = context as Record<string, unknown>;
|
|
88
|
+
for (const [name, Token] of Object.entries(injects)) {
|
|
89
|
+
scoped[name] = new (Token as ClassType)();
|
|
90
|
+
}
|
|
91
|
+
return handler(context);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
} as unknown as RegistryFactory;
|
|
95
|
+
|
|
96
|
+
RegistryImpl.empty = <Context>() => build<Context>(new Map());
|
|
97
|
+
RegistryImpl.add = <Context>(entry: RegistryEntryClass<Context>) =>
|
|
98
|
+
RegistryImpl.empty<Context>().add(entry);
|
|
99
|
+
|
|
100
|
+
export const Registry = RegistryImpl;
|