@procedures/schedule 0.4.0 → 0.6.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/CHANGELOG.md +33 -0
- package/README.md +55 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/cron.d.ts +36 -6
- package/dist/cron.d.ts.map +1 -1
- package/dist/cron.js +265 -41
- package/dist/cron.js.map +1 -1
- package/dist/errors.d.ts +4 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/module.d.ts +5 -0
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +8 -2
- package/dist/module.js.map +1 -1
- package/dist/procedures/schedule/add.d.ts +5 -2
- package/dist/procedures/schedule/add.d.ts.map +1 -1
- package/dist/procedures/schedule/add.js +15 -10
- package/dist/procedures/schedule/add.js.map +1 -1
- package/dist/procedures/schedule/list.js +1 -1
- package/dist/procedures/schedule/list.js.map +1 -1
- package/dist/procedures/schedule/next.d.ts +1 -1
- package/dist/procedures/schedule/next.d.ts.map +1 -1
- package/dist/procedures/schedule/next.js +2 -2
- package/dist/procedures/schedule/next.js.map +1 -1
- package/dist/programs.d.ts +3 -1
- package/dist/programs.d.ts.map +1 -1
- package/dist/programs.js.map +1 -1
- package/dist/scheduler.d.ts +9 -0
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +11 -6
- package/dist/scheduler.js.map +1 -1
- package/dist/store.d.ts +50 -5
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +161 -26
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +8 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/cron.ts +277 -48
- package/src/errors.ts +4 -0
- package/src/index.ts +10 -2
- package/src/module.ts +13 -2
- package/src/procedures/schedule/add.ts +15 -10
- package/src/procedures/schedule/list.ts +1 -1
- package/src/procedures/schedule/next.ts +3 -3
- package/src/programs.ts +3 -1
- package/src/scheduler.ts +16 -6
- package/src/store.ts +166 -24
- package/src/types.ts +8 -4
package/src/cron.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cron expressions (roadmap R46): the parse and the next times.
|
|
3
3
|
*
|
|
4
|
-
* This package parses cron itself. The format is small, and
|
|
5
|
-
* zone database or a second format that this package does not need.
|
|
4
|
+
* This package parses cron itself. The format is small, and `Intl` gives the offsets of each time
|
|
5
|
+
* zone. A dependency gives a time zone database or a second format that this package does not need.
|
|
6
6
|
*
|
|
7
7
|
* - 5 fields: minute (0-59), hour (0-23), day of the month (1-31), month (1-12), day of the week
|
|
8
8
|
* (0-7, with 0 and 7 for Sunday).
|
|
@@ -13,8 +13,16 @@
|
|
|
13
13
|
* - The days, as in Vixie cron: when the two day fields have a limit (they do not start with
|
|
14
14
|
* `*`), a day matches when one of the two matches. Else the field with a limit decides.
|
|
15
15
|
*
|
|
16
|
-
* The
|
|
17
|
-
*
|
|
16
|
+
* The time zone is `utc`, `local` (the time zone of the host) or an IANA name, for example
|
|
17
|
+
* `Europe/Berlin`. The changes of daylight saving time follow Vixie cron:
|
|
18
|
+
*
|
|
19
|
+
* - A fixed time has no `*` at the start of its minute field and of its hour field: `30 2 * * *`.
|
|
20
|
+
* - A fixed time in the hour that does not exist runs once, at the first minute after the change.
|
|
21
|
+
* Two such times of one expression give one run.
|
|
22
|
+
* - A fixed time in the hour that occurs twice runs once, at its first occurrence.
|
|
23
|
+
* - An expression with `*` at the start of its minute field or of its hour field follows the
|
|
24
|
+
* clock. It has no run for a time that does not exist, and it runs at the two occurrences of a
|
|
25
|
+
* time that occurs twice. Thus `0 * * * *` runs each hour of real time.
|
|
18
26
|
*/
|
|
19
27
|
|
|
20
28
|
import { ScheduleError } from "./errors.js";
|
|
@@ -33,6 +41,11 @@ export interface CronExpression {
|
|
|
33
41
|
readonly dayLimited: boolean;
|
|
34
42
|
/** True when the field of the day of the week does not start with `*`. */
|
|
35
43
|
readonly weekdayLimited: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* True when the minute field and the hour field do not start with `*`. A fixed time obeys the
|
|
46
|
+
* rules of Vixie cron for the changes of daylight saving time (see the comment of the file).
|
|
47
|
+
*/
|
|
48
|
+
readonly fixedTime: boolean;
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
@@ -133,10 +146,107 @@ export function parseCron(text: string): CronExpression {
|
|
|
133
146
|
weekdays,
|
|
134
147
|
dayLimited: !fields[2]!.startsWith("*"),
|
|
135
148
|
weekdayLimited: !fields[4]!.startsWith("*"),
|
|
149
|
+
fixedTime: !fields[0]!.startsWith("*") && !fields[1]!.startsWith("*"),
|
|
136
150
|
};
|
|
137
151
|
}
|
|
138
152
|
|
|
139
|
-
|
|
153
|
+
// =============================================================================
|
|
154
|
+
// Time zones
|
|
155
|
+
// =============================================================================
|
|
156
|
+
|
|
157
|
+
const MINUTE = 60_000;
|
|
158
|
+
const DAY = 86_400_000;
|
|
159
|
+
|
|
160
|
+
/** The form of an IANA name: `Europe/Berlin`, `UTC`, `Etc/GMT+5`. An offset such as `+05:30` is not one. */
|
|
161
|
+
const IANA_NAME = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
|
|
162
|
+
|
|
163
|
+
/** The names that `checkTimeZone` accepted, with its result: a file store examines each schedule at each read. */
|
|
164
|
+
const checked = new Map<string, string>();
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* This function checks a time zone and gives its name: `utc`, `local`, or an IANA name that `Intl`
|
|
168
|
+
* knows. A name that differs from the name of `Intl` only by the case gets the case of `Intl`. The
|
|
169
|
+
* function throws a `BAD_TIME_ZONE` error for another name.
|
|
170
|
+
*/
|
|
171
|
+
export function checkTimeZone(timeZone: string): ScheduleTimeZone {
|
|
172
|
+
if (timeZone === "utc" || timeZone === "local") return timeZone;
|
|
173
|
+
const known = checked.get(timeZone);
|
|
174
|
+
if (known !== undefined) return known;
|
|
175
|
+
let resolved: string | undefined;
|
|
176
|
+
if (timeZone.length <= 100 && IANA_NAME.test(timeZone)) {
|
|
177
|
+
try {
|
|
178
|
+
resolved = new Intl.DateTimeFormat("en-US", { timeZone }).resolvedOptions().timeZone;
|
|
179
|
+
} catch {
|
|
180
|
+
resolved = undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (resolved === undefined) {
|
|
184
|
+
throw new ScheduleError("BAD_TIME_ZONE", `The time zone ${JSON.stringify(timeZone)} is not utc, local or an IANA name that Intl knows, for example Europe/Berlin`);
|
|
185
|
+
}
|
|
186
|
+
const name = resolved.toLowerCase() === timeZone.toLowerCase() ? resolved : timeZone;
|
|
187
|
+
// The cache stays small: a process uses few zones
|
|
188
|
+
if (checked.size >= 1000) checked.clear();
|
|
189
|
+
checked.set(timeZone, name);
|
|
190
|
+
return name;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** This function gives the IANA name of the time zone of the process, from `Intl`. */
|
|
194
|
+
export function hostTimeZone(): string {
|
|
195
|
+
return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The offsets of one time zone of `Intl`. */
|
|
199
|
+
class Zone {
|
|
200
|
+
private readonly format: Intl.DateTimeFormat;
|
|
201
|
+
|
|
202
|
+
constructor(name: string) {
|
|
203
|
+
this.format = new Intl.DateTimeFormat("en-US", {
|
|
204
|
+
timeZone: name,
|
|
205
|
+
hourCycle: "h23",
|
|
206
|
+
year: "numeric",
|
|
207
|
+
month: "numeric",
|
|
208
|
+
day: "numeric",
|
|
209
|
+
hour: "numeric",
|
|
210
|
+
minute: "numeric",
|
|
211
|
+
second: "numeric",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** This method gives the offset of the zone at a time: the wall time minus the time, in milliseconds. */
|
|
216
|
+
offset(time: number): number {
|
|
217
|
+
const value: Partial<Record<Intl.DateTimeFormatPartTypes, number>> = {};
|
|
218
|
+
for (const part of this.format.formatToParts(time)) if (part.type !== "literal") value[part.type] = Number(part.value);
|
|
219
|
+
const wall = Date.UTC(value.year ?? 1970, (value.month ?? 1) - 1, value.day ?? 1, (value.hour ?? 0) % 24, value.minute ?? 0, value.second ?? 0);
|
|
220
|
+
return wall - Math.floor(time / 1000) * 1000;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The zones, by the name in lower case: `Intl` ignores the case of a name. */
|
|
225
|
+
const zones = new Map<string, Zone>();
|
|
226
|
+
|
|
227
|
+
/** The zone of a name. It throws `BAD_TIME_ZONE` for a name that `Intl` does not know. */
|
|
228
|
+
function zoneOf(timeZone: string): Zone {
|
|
229
|
+
const key = timeZone.toLowerCase();
|
|
230
|
+
let zone = zones.get(key);
|
|
231
|
+
if (zone === undefined) {
|
|
232
|
+
zone = new Zone(checkTimeZone(timeZone));
|
|
233
|
+
zones.set(key, zone);
|
|
234
|
+
}
|
|
235
|
+
return zone;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The IANA name of a time zone that is not `utc`: `local` is the time zone of the host. */
|
|
239
|
+
const ianaName = (timeZone: ScheduleTimeZone): string => (timeZone === "local" ? hostTimeZone() : timeZone);
|
|
240
|
+
|
|
241
|
+
// =============================================================================
|
|
242
|
+
// Wall times
|
|
243
|
+
// =============================================================================
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A wall time is the time that a clock shows, as milliseconds with the arithmetic of UTC: the
|
|
247
|
+
* fields of `new Date(wall)` in UTC are the fields of the clock. A time is a real time. In a zone,
|
|
248
|
+
* the wall time of a time is the time plus the offset of the zone at that time.
|
|
249
|
+
*/
|
|
140
250
|
interface Fields {
|
|
141
251
|
year: number;
|
|
142
252
|
month: number;
|
|
@@ -146,28 +256,11 @@ interface Fields {
|
|
|
146
256
|
weekday: number;
|
|
147
257
|
}
|
|
148
258
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
make(year: number, month: number, day: number, hour: number, minute: number): number;
|
|
259
|
+
function fieldsOf(wall: number): Fields {
|
|
260
|
+
const date = new Date(wall);
|
|
261
|
+
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate(), hour: date.getUTCHours(), minute: date.getUTCMinutes(), weekday: date.getUTCDay() };
|
|
153
262
|
}
|
|
154
263
|
|
|
155
|
-
const UTC: Calendar = {
|
|
156
|
-
fields(time) {
|
|
157
|
-
const date = new Date(time);
|
|
158
|
-
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate(), hour: date.getUTCHours(), minute: date.getUTCMinutes(), weekday: date.getUTCDay() };
|
|
159
|
-
},
|
|
160
|
-
make: (year, month, day, hour, minute) => Date.UTC(year, month - 1, day, hour, minute),
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
const LOCAL: Calendar = {
|
|
164
|
-
fields(time) {
|
|
165
|
-
const date = new Date(time);
|
|
166
|
-
return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), hour: date.getHours(), minute: date.getMinutes(), weekday: date.getDay() };
|
|
167
|
-
},
|
|
168
|
-
make: (year, month, day, hour, minute) => new Date(year, month - 1, day, hour, minute).getTime(),
|
|
169
|
-
};
|
|
170
|
-
|
|
171
264
|
/** The years that a search covers: Feb 29 can be 8 years away (2096 to 2104). */
|
|
172
265
|
const SEARCH_YEARS = 9;
|
|
173
266
|
|
|
@@ -182,28 +275,154 @@ function dayMatches(cron: CronExpression, fields: Fields): boolean {
|
|
|
182
275
|
}
|
|
183
276
|
|
|
184
277
|
/**
|
|
185
|
-
* This function gives the first time of the expression after `after` (
|
|
186
|
-
* or undefined when the expression has no time in the next 9 years.
|
|
278
|
+
* This function gives the first wall time of the expression after the wall time `after` (a whole
|
|
279
|
+
* minute), or undefined when the expression has no wall time in the next 9 years.
|
|
187
280
|
*/
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const lastYear = calendar.fields(after).year + SEARCH_YEARS;
|
|
281
|
+
function nextWall(cron: CronExpression, after: number): number | undefined {
|
|
282
|
+
let wall = after + MINUTE;
|
|
283
|
+
const lastYear = fieldsOf(after).year + SEARCH_YEARS;
|
|
192
284
|
for (let step = 0; step < 1_000_000; step++) {
|
|
193
|
-
const fields =
|
|
285
|
+
const fields = fieldsOf(wall);
|
|
194
286
|
if (fields.year > lastYear) return undefined;
|
|
195
|
-
|
|
196
|
-
if (!cron
|
|
197
|
-
else if (!
|
|
198
|
-
else if (!cron.
|
|
199
|
-
else
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
287
|
+
if (!cron.months.has(fields.month)) wall = Date.UTC(fields.year, fields.month, 1);
|
|
288
|
+
else if (!dayMatches(cron, fields)) wall = Date.UTC(fields.year, fields.month - 1, fields.day + 1);
|
|
289
|
+
else if (!cron.hours.has(fields.hour)) wall = Date.UTC(fields.year, fields.month - 1, fields.day, fields.hour + 1);
|
|
290
|
+
else if (!cron.minutes.has(fields.minute)) wall += MINUTE;
|
|
291
|
+
else return wall;
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* This function gives the first minute in `(from, to]` with another offset than `from`. The
|
|
298
|
+
* offsets of `from` and `to` differ, and one change of the offset is between them.
|
|
299
|
+
*/
|
|
300
|
+
function changeIn(zone: Zone, from: number, to: number): number {
|
|
301
|
+
const offset = zone.offset(from);
|
|
302
|
+
let low = from;
|
|
303
|
+
let high = to;
|
|
304
|
+
while (high - low > MINUTE) {
|
|
305
|
+
const middle = low + Math.floor((high - low) / 2 / MINUTE) * MINUTE;
|
|
306
|
+
if (zone.offset(middle) === offset) low = middle;
|
|
307
|
+
else high = middle;
|
|
308
|
+
}
|
|
309
|
+
return high;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The times of a wall time, sorted: one time, two times in the hour that occurs twice, or none in
|
|
314
|
+
* the hour that does not exist. For none, `change` is the time of the change: its wall time is
|
|
315
|
+
* the first wall time after the gap.
|
|
316
|
+
*/
|
|
317
|
+
function timesOf(zone: Zone, wall: number): { times: number[]; change?: number } {
|
|
318
|
+
// The offsets a day before and a day after: a change of the clock near the wall time is between them
|
|
319
|
+
const before = zone.offset(wall - DAY);
|
|
320
|
+
const after = zone.offset(wall + DAY);
|
|
321
|
+
const times = [...new Set([wall - before, wall - after])].filter((time) => time + zone.offset(time) === wall).sort((a, b) => a - b);
|
|
322
|
+
if (times.length > 0 || before >= after) return { times };
|
|
323
|
+
return { times, change: changeIn(zone, wall - after, wall - before) };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The highest wall time of the minutes up to the minute `time`. After a change back, the clock
|
|
328
|
+
* shows an hour a second time, and this wall time stays above the wall time of `time`.
|
|
329
|
+
*/
|
|
330
|
+
function highWall(zone: Zone, time: number): number {
|
|
331
|
+
const offset = zone.offset(time);
|
|
332
|
+
const earlier = zone.offset(time - DAY);
|
|
333
|
+
if (earlier <= offset) return time + offset;
|
|
334
|
+
const change = changeIn(zone, time - DAY, time);
|
|
335
|
+
return Math.max(time + offset, change - MINUTE + earlier);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The next time of a fixed time after the minute `after`. Each wall time runs at the first time
|
|
340
|
+
* whose wall time is the same or later. Thus a wall time in a gap runs at the change, and a wall
|
|
341
|
+
* time that occurs twice runs at its first occurrence. The wall times after the highest wall time
|
|
342
|
+
* of `after` are the wall times that did not run.
|
|
343
|
+
*/
|
|
344
|
+
function nextFixed(cron: CronExpression, zone: Zone, after: number): number | undefined {
|
|
345
|
+
let wall = highWall(zone, after);
|
|
346
|
+
for (let step = 0; step < 1000; step++) {
|
|
347
|
+
const next = nextWall(cron, wall);
|
|
348
|
+
if (next === undefined) return undefined;
|
|
349
|
+
const { times, change } = timesOf(zone, next);
|
|
350
|
+
if (times.length > 0) return times[0];
|
|
351
|
+
if (change !== undefined) return change;
|
|
352
|
+
wall = next;
|
|
203
353
|
}
|
|
204
354
|
return undefined;
|
|
205
355
|
}
|
|
206
356
|
|
|
357
|
+
/**
|
|
358
|
+
* The next time of an expression that follows the clock, after the minute `after`: the first time
|
|
359
|
+
* whose wall time matches. A wall time in a gap has no time. A wall time that occurs twice has two.
|
|
360
|
+
*/
|
|
361
|
+
function nextByClock(cron: CronExpression, zone: Zone, after: number): number | undefined {
|
|
362
|
+
// The first occurrences: the wall times after the highest wall time
|
|
363
|
+
let first: number | undefined;
|
|
364
|
+
let wall = highWall(zone, after);
|
|
365
|
+
for (let step = 0; step < 1000 && first === undefined; step++) {
|
|
366
|
+
const next = nextWall(cron, wall);
|
|
367
|
+
if (next === undefined) break;
|
|
368
|
+
const { times, change } = timesOf(zone, next);
|
|
369
|
+
if (times.length > 0) first = times[0];
|
|
370
|
+
// A wall time in a gap has no time: the search goes on after the gap
|
|
371
|
+
else wall = change === undefined ? next : change + zone.offset(change) - MINUTE;
|
|
372
|
+
}
|
|
373
|
+
const second = secondOccurrence(cron, zone, after);
|
|
374
|
+
if (first === undefined) return second;
|
|
375
|
+
return second === undefined ? first : Math.min(first, second);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The first time after the minute `after` in the hour that the clock shows a second time, when a
|
|
380
|
+
* change back is near `after`, or undefined.
|
|
381
|
+
*/
|
|
382
|
+
function secondOccurrence(cron: CronExpression, zone: Zone, after: number): number | undefined {
|
|
383
|
+
const offset = zone.offset(after);
|
|
384
|
+
const earlier = zone.offset(after - DAY);
|
|
385
|
+
const later = zone.offset(after + DAY);
|
|
386
|
+
let change: number;
|
|
387
|
+
let shift: number;
|
|
388
|
+
let repeated: number;
|
|
389
|
+
if (earlier > offset) {
|
|
390
|
+
change = changeIn(zone, after - DAY, after);
|
|
391
|
+
shift = earlier - offset;
|
|
392
|
+
repeated = offset;
|
|
393
|
+
} else if (later < offset) {
|
|
394
|
+
change = changeIn(zone, after, after + DAY);
|
|
395
|
+
shift = offset - later;
|
|
396
|
+
repeated = later;
|
|
397
|
+
} else {
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
// The second occurrences are the times from the change to the end of the shift, with the offset `repeated`
|
|
401
|
+
const end = change + shift;
|
|
402
|
+
const start = Math.max(after + MINUTE, change);
|
|
403
|
+
if (start >= end) return undefined;
|
|
404
|
+
const wall = nextWall(cron, start + repeated - MINUTE);
|
|
405
|
+
if (wall === undefined) return undefined;
|
|
406
|
+
const time = wall - repeated;
|
|
407
|
+
return time < end ? time : undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// =============================================================================
|
|
411
|
+
// The next times
|
|
412
|
+
// =============================================================================
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* This function gives the first time of the expression after `after` (milliseconds since 1970),
|
|
416
|
+
* or undefined when the expression has no time in the next 9 years. The time zone is `utc`,
|
|
417
|
+
* `local` or an IANA name. An unknown name gives a `BAD_TIME_ZONE` error.
|
|
418
|
+
*/
|
|
419
|
+
export function nextTime(cron: CronExpression, after: number, timeZone: ScheduleTimeZone = "local"): number | undefined {
|
|
420
|
+
const minute = Math.floor(after / MINUTE) * MINUTE;
|
|
421
|
+
if (timeZone === "utc") return nextWall(cron, minute);
|
|
422
|
+
const zone = zoneOf(ianaName(timeZone));
|
|
423
|
+
return cron.fixedTime ? nextFixed(cron, zone, minute) : nextByClock(cron, zone, minute);
|
|
424
|
+
}
|
|
425
|
+
|
|
207
426
|
/** This function gives the next `count` times of the expression after `after`. */
|
|
208
427
|
export function nextTimes(cron: CronExpression, after: number, count: number, timeZone: ScheduleTimeZone = "local"): number[] {
|
|
209
428
|
const times: number[] = [];
|
|
@@ -217,14 +436,24 @@ export function nextTimes(cron: CronExpression, after: number, count: number, ti
|
|
|
217
436
|
return times;
|
|
218
437
|
}
|
|
219
438
|
|
|
220
|
-
const pad = (value: number
|
|
439
|
+
const pad = (value: number): string => String(value).padStart(2, "0");
|
|
221
440
|
|
|
222
|
-
/**
|
|
441
|
+
/**
|
|
442
|
+
* The ISO text of a time in a time zone: `2026-10-17T09:00:00Z` in UTC, or with the offset of the
|
|
443
|
+
* zone at that time: `2026-10-17T09:00:00+02:00`. An offset of 0 gives `Z`.
|
|
444
|
+
*/
|
|
223
445
|
export function formatTime(time: number, timeZone: ScheduleTimeZone = "local"): string {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
446
|
+
if (timeZone === "utc") return `${new Date(time).toISOString().slice(0, 19)}Z`;
|
|
447
|
+
const offset = zoneOf(ianaName(timeZone)).offset(time);
|
|
448
|
+
const minutes = Math.round(offset / MINUTE);
|
|
449
|
+
const zone = minutes === 0 ? "Z" : `${minutes > 0 ? "+" : "-"}${pad(Math.floor(Math.abs(minutes) / 60))}:${pad(Math.abs(minutes) % 60)}`;
|
|
450
|
+
return `${new Date(time + offset).toISOString().slice(0, 19)}${zone}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* This function gives the time zone of a computation. The zone `local` becomes `local` (the zone
|
|
455
|
+
* of the process) or the zone that the host gives for it.
|
|
456
|
+
*/
|
|
457
|
+
export function zoneFor(timeZone: ScheduleTimeZone, local: string | undefined): ScheduleTimeZone {
|
|
458
|
+
return timeZone === "local" && local !== undefined ? local : timeZone;
|
|
230
459
|
}
|
package/src/errors.ts
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
export type ScheduleErrorCode =
|
|
7
7
|
/** The cron expression is not correct, or it has no time in the next 9 years. */
|
|
8
8
|
| "BAD_CRON"
|
|
9
|
+
/** The time zone is not `utc`, `local` or an IANA name that `Intl` knows. */
|
|
10
|
+
| "BAD_TIME_ZONE"
|
|
11
|
+
/** Another process holds the lock file of a `FileScheduleStore` for longer than the wait. */
|
|
12
|
+
| "LOCKED"
|
|
9
13
|
/** A schedule of the name exists, and the input does not replace it. */
|
|
10
14
|
| "EXISTS"
|
|
11
15
|
/** No stored program has the key: not in the registry, not in the program store. */
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,16 @@ export * from "./module.js";
|
|
|
3
3
|
export * from "./types.js";
|
|
4
4
|
export { scheduleAdd, scheduleList, scheduleRemove, scheduleNext, viewOf } from "./procedures/schedule/index.js";
|
|
5
5
|
// The cron expressions, the stores and the scheduler (roadmap R46)
|
|
6
|
-
export { CRON_NAMES, formatTime, nextTime, nextTimes, parseCron, type CronExpression } from "./cron.js";
|
|
6
|
+
export { checkTimeZone, CRON_NAMES, formatTime, hostTimeZone, nextTime, nextTimes, parseCron, zoneFor, type CronExpression } from "./cron.js";
|
|
7
7
|
export { ScheduleError, type ScheduleErrorCode } from "./errors.js";
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
checkSchedule,
|
|
10
|
+
DEFAULT_LOCK_STALE_MS,
|
|
11
|
+
defaultScheduleStore,
|
|
12
|
+
FileScheduleStore,
|
|
13
|
+
MemoryScheduleStore,
|
|
14
|
+
type FileScheduleStoreOptions,
|
|
15
|
+
type ScheduleStore,
|
|
16
|
+
} from "./store.js";
|
|
9
17
|
export { DEFAULT_SCHEDULE_SETTINGS, findProgram, PROGRAM_KEY, type ScheduleSettings } from "./programs.js";
|
|
10
18
|
export { DEFAULT_RUN_TIMEOUT_MS, Scheduler, type ScheduleRunEvent, type SchedulerOptions } from "./scheduler.js";
|
package/src/module.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { scheduleAdd } from "./procedures/schedule/add.js";
|
|
|
25
25
|
import { scheduleList } from "./procedures/schedule/list.js";
|
|
26
26
|
import { scheduleRemove } from "./procedures/schedule/remove.js";
|
|
27
27
|
import { scheduleNext } from "./procedures/schedule/next.js";
|
|
28
|
+
import { checkTimeZone } from "./cron.js";
|
|
28
29
|
import { DEFAULT_SCHEDULE_SETTINGS, PROGRAM_KEY, type ScheduleSettings } from "./programs.js";
|
|
29
30
|
import type { ScheduleStore } from "./store.js";
|
|
30
31
|
import type {
|
|
@@ -44,7 +45,11 @@ import type {
|
|
|
44
45
|
// =============================================================================
|
|
45
46
|
|
|
46
47
|
const cron = z.string().min(1).max(200).describe("The cron expression: 5 fields (minute hour day month weekday), or @hourly, @daily, @weekly, @monthly, @yearly");
|
|
47
|
-
const timeZone = z
|
|
48
|
+
const timeZone = z
|
|
49
|
+
.string()
|
|
50
|
+
.min(1)
|
|
51
|
+
.max(100)
|
|
52
|
+
.describe("The time zone of the expression: utc, local (default) or an IANA name such as Europe/Berlin");
|
|
48
53
|
const scheduleId = z
|
|
49
54
|
.string()
|
|
50
55
|
.regex(/^[A-Za-z0-9._:-]{1,200}$/, "Letters, digits, and . _ : -")
|
|
@@ -63,7 +68,7 @@ const view = z.object({
|
|
|
63
68
|
cron: z.string(),
|
|
64
69
|
program: z.string(),
|
|
65
70
|
input: z.unknown().optional(),
|
|
66
|
-
timeZone: z.
|
|
71
|
+
timeZone: z.string().describe("The time zone: utc, local or an IANA name"),
|
|
67
72
|
createdAt: z.number().describe("When the schedule was added, in milliseconds since 1970"),
|
|
68
73
|
capability: z
|
|
69
74
|
.custom<NonNullable<Schedule["capability"]>>((value) => value !== null && typeof value === "object")
|
|
@@ -196,6 +201,11 @@ export interface ScheduleModuleOptions {
|
|
|
196
201
|
readonly store?: ScheduleStore | undefined;
|
|
197
202
|
/** The wall time, in milliseconds since 1970. The default is `Date.now`. A test gives the time of its fake clock. */
|
|
198
203
|
readonly now?: (() => number) | undefined;
|
|
204
|
+
/**
|
|
205
|
+
* The IANA time zone of the schedules with the time zone `local`, for example `Europe/Berlin`.
|
|
206
|
+
* The default is the time zone of the process. A host gives the same zone to its `Scheduler`.
|
|
207
|
+
*/
|
|
208
|
+
readonly timeZone?: string | undefined;
|
|
199
209
|
}
|
|
200
210
|
|
|
201
211
|
/**
|
|
@@ -214,6 +224,7 @@ export function createScheduleModule(options: ScheduleModuleOptions = {}): Proce
|
|
|
214
224
|
const settings: ScheduleSettings = {
|
|
215
225
|
store: options.store ?? DEFAULT_SCHEDULE_SETTINGS.store,
|
|
216
226
|
now: options.now ?? DEFAULT_SCHEDULE_SETTINGS.now,
|
|
227
|
+
...(options.timeZone !== undefined ? { timeZone: checkTimeZone(options.timeZone) } : {}),
|
|
217
228
|
};
|
|
218
229
|
return defineModule("@procedures/schedule", scheduleProcedures(settings));
|
|
219
230
|
}
|
|
@@ -7,20 +7,25 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { PROCEDURE_REGISTRY, type ProcedureContext } from "@procedures/client";
|
|
10
|
-
import { formatTime, nextTime, parseCron } from "../../cron.js";
|
|
10
|
+
import { checkTimeZone, formatTime, nextTime, parseCron, zoneFor } from "../../cron.js";
|
|
11
11
|
import { ScheduleError } from "../../errors.js";
|
|
12
12
|
import { DEFAULT_SCHEDULE_SETTINGS, findProgram, type ScheduleSettings } from "../../programs.js";
|
|
13
13
|
import type { Schedule, ScheduleAddInput, ScheduleAddOutput, ScheduleView } from "../../types.js";
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The view of a schedule: with its next time after `now`. The optional `local` is the IANA time
|
|
17
|
+
* zone of `local`. Without it, `local` is the time zone of the process.
|
|
18
|
+
*/
|
|
19
|
+
export function viewOf(schedule: Schedule, now: number, local?: string): ScheduleView {
|
|
20
|
+
let nextRunAt: string | null;
|
|
18
21
|
try {
|
|
19
|
-
|
|
22
|
+
const zone = zoneFor(schedule.timeZone, local);
|
|
23
|
+
const next = nextTime(parseCron(schedule.cron), now, zone);
|
|
24
|
+
nextRunAt = next === undefined ? null : formatTime(next, zone);
|
|
20
25
|
} catch {
|
|
21
|
-
|
|
26
|
+
nextRunAt = null;
|
|
22
27
|
}
|
|
23
|
-
return { ...schedule, nextRunAt
|
|
28
|
+
return { ...schedule, nextRunAt };
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/** The capability of the caller to keep: its clearance and its permitted effects, without its deadline. */
|
|
@@ -38,9 +43,9 @@ function callerCapability(ctx: Pick<ProcedureContext, "capability"> | undefined)
|
|
|
38
43
|
*/
|
|
39
44
|
export async function scheduleAdd(input: ScheduleAddInput, ctx?: Pick<ProcedureContext, "capability" | "registry">, settings: ScheduleSettings = DEFAULT_SCHEDULE_SETTINGS): Promise<ScheduleAddOutput> {
|
|
40
45
|
const now = settings.now();
|
|
41
|
-
const timeZone = input.timeZone ?? "local";
|
|
42
46
|
const cron = parseCron(input.cron);
|
|
43
|
-
|
|
47
|
+
const timeZone = checkTimeZone(input.timeZone ?? "local");
|
|
48
|
+
if (nextTime(cron, now, zoneFor(timeZone, settings.timeZone)) === undefined) {
|
|
44
49
|
throw new ScheduleError("BAD_CRON", `The cron expression ${JSON.stringify(input.cron)} has no time in the next 9 years`);
|
|
45
50
|
}
|
|
46
51
|
await findProgram(ctx?.registry ?? PROCEDURE_REGISTRY, input.program);
|
|
@@ -62,5 +67,5 @@ export async function scheduleAdd(input: ScheduleAddInput, ctx?: Pick<ProcedureC
|
|
|
62
67
|
throw new ScheduleError("EXISTS", `A schedule named ${id} exists: give replace: true, or another id`);
|
|
63
68
|
}
|
|
64
69
|
await settings.store.put(schedule);
|
|
65
|
-
return { schedule: viewOf(schedule, now) };
|
|
70
|
+
return { schedule: viewOf(schedule, now, settings.timeZone) };
|
|
66
71
|
}
|
|
@@ -15,6 +15,6 @@ export async function scheduleList(input: ScheduleListInput, settings: ScheduleS
|
|
|
15
15
|
const now = settings.now();
|
|
16
16
|
const schedules = await settings.store.list();
|
|
17
17
|
return {
|
|
18
|
-
schedules: schedules.filter((schedule) => input.program === undefined || schedule.program === input.program).map((schedule) => viewOf(schedule, now)),
|
|
18
|
+
schedules: schedules.filter((schedule) => input.program === undefined || schedule.program === input.program).map((schedule) => viewOf(schedule, now, settings.timeZone)),
|
|
19
19
|
};
|
|
20
20
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* from the time.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { formatTime, nextTimes, parseCron } from "../../cron.js";
|
|
8
|
+
import { checkTimeZone, formatTime, nextTimes, parseCron, zoneFor } from "../../cron.js";
|
|
9
9
|
import { ScheduleError } from "../../errors.js";
|
|
10
10
|
import { DEFAULT_SCHEDULE_SETTINGS, type ScheduleSettings } from "../../programs.js";
|
|
11
11
|
import type { ScheduleNextInput, ScheduleNextOutput } from "../../types.js";
|
|
@@ -13,9 +13,9 @@ import type { ScheduleNextInput, ScheduleNextOutput } from "../../types.js";
|
|
|
13
13
|
/**
|
|
14
14
|
* The next times of a cron expression
|
|
15
15
|
*/
|
|
16
|
-
export async function scheduleNext(input: ScheduleNextInput, settings: Pick<ScheduleSettings, "now"> = DEFAULT_SCHEDULE_SETTINGS): Promise<ScheduleNextOutput> {
|
|
17
|
-
const timeZone = input.timeZone ?? "local";
|
|
16
|
+
export async function scheduleNext(input: ScheduleNextInput, settings: Pick<ScheduleSettings, "now" | "timeZone"> = DEFAULT_SCHEDULE_SETTINGS): Promise<ScheduleNextOutput> {
|
|
18
17
|
const cron = parseCron(input.cron);
|
|
18
|
+
const timeZone = zoneFor(checkTimeZone(input.timeZone ?? "local"), settings.timeZone);
|
|
19
19
|
let after = settings.now();
|
|
20
20
|
if (input.after !== undefined) {
|
|
21
21
|
after = Date.parse(input.after);
|
package/src/programs.ts
CHANGED
|
@@ -11,11 +11,13 @@ import { getProgramStore } from "@procedures/store";
|
|
|
11
11
|
import { ScheduleError } from "./errors.js";
|
|
12
12
|
import { defaultScheduleStore, type ScheduleStore } from "./store.js";
|
|
13
13
|
|
|
14
|
-
/** What the procedures of a module use: the store
|
|
14
|
+
/** What the procedures of a module use: the store, the wall clock and the time zone of `local`. */
|
|
15
15
|
export interface ScheduleSettings {
|
|
16
16
|
readonly store: ScheduleStore;
|
|
17
17
|
/** The wall time, in milliseconds since 1970. */
|
|
18
18
|
readonly now: () => number;
|
|
19
|
+
/** The IANA time zone of the schedules with the time zone `local`. Without it, `local` is the time zone of the process. */
|
|
20
|
+
readonly timeZone?: string | undefined;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** The settings of the default module: `defaultScheduleStore` and `Date.now`. */
|
package/src/scheduler.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* missed during a run or a slow minute give one run, not one for each time.
|
|
18
18
|
* - The timers use an injectable `Clock` (`after` and `now`), and the calendar uses an injectable
|
|
19
19
|
* wall clock (`now`, default `Date.now`). A test gives `fakeClock` of `@mark1russell7/service/testing`.
|
|
20
|
+
* - Each schedule runs in its time zone: `utc`, `local` or an IANA name. The option `timeZone`
|
|
21
|
+
* gives the zone of `local`. The changes of daylight saving time follow Vixie cron (`cron.ts`).
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
import {
|
|
@@ -36,7 +38,7 @@ import {
|
|
|
36
38
|
type InvokeOptions,
|
|
37
39
|
type ProcedureRegistry,
|
|
38
40
|
} from "@procedures/client";
|
|
39
|
-
import { nextTime, parseCron } from "./cron.js";
|
|
41
|
+
import { checkTimeZone, nextTime, parseCron, zoneFor } from "./cron.js";
|
|
40
42
|
import { ScheduleError } from "./errors.js";
|
|
41
43
|
import { findProgram } from "./programs.js";
|
|
42
44
|
import { defaultScheduleStore, type ScheduleStore } from "./store.js";
|
|
@@ -70,6 +72,11 @@ export interface SchedulerOptions {
|
|
|
70
72
|
readonly clock?: Clock | undefined;
|
|
71
73
|
/** The wall time, in milliseconds since 1970. The default is `Date.now`. */
|
|
72
74
|
readonly now?: (() => number) | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* The IANA time zone of the schedules with the time zone `local`, for example `Europe/Berlin`.
|
|
77
|
+
* The default is the time zone of the process. The host gives the same zone to `createScheduleModule()`.
|
|
78
|
+
*/
|
|
79
|
+
readonly timeZone?: string | undefined;
|
|
73
80
|
/** The time limit of a run, in milliseconds. 0: no limit. The default is 10 minutes. */
|
|
74
81
|
readonly runTimeoutMs?: number | undefined;
|
|
75
82
|
/** This function gets each run and each skipped time. */
|
|
@@ -93,10 +100,10 @@ function errorInfo(error: unknown): { code?: string; message: string } {
|
|
|
93
100
|
return typeof code === "string" ? { code, message } : { message };
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
/** The next time of a schedule after a time, or undefined for an expression that is not correct. */
|
|
97
|
-
function nextOf(schedule: Schedule, after: number): number | undefined {
|
|
103
|
+
/** The next time of a schedule after a time, or undefined for an expression or a time zone that is not correct. */
|
|
104
|
+
function nextOf(schedule: Schedule, after: number, local: string | undefined): number | undefined {
|
|
98
105
|
try {
|
|
99
|
-
return nextTime(parseCron(schedule.cron), after, schedule.timeZone);
|
|
106
|
+
return nextTime(parseCron(schedule.cron), after, zoneFor(schedule.timeZone, local));
|
|
100
107
|
} catch {
|
|
101
108
|
return undefined;
|
|
102
109
|
}
|
|
@@ -109,6 +116,8 @@ export class Scheduler {
|
|
|
109
116
|
private readonly clock: Clock;
|
|
110
117
|
private readonly now: () => number;
|
|
111
118
|
private readonly runTimeoutMs: number;
|
|
119
|
+
/** The IANA time zone of `local`, or undefined for the time zone of the process. */
|
|
120
|
+
private readonly timeZone: string | undefined;
|
|
112
121
|
private readonly options: SchedulerOptions;
|
|
113
122
|
private readonly due = new Map<string, Due>();
|
|
114
123
|
private readonly runs = new Map<string, { promise: Promise<void>; controller: AbortController }>();
|
|
@@ -124,6 +133,7 @@ export class Scheduler {
|
|
|
124
133
|
this.now = options.now ?? (() => Date.now());
|
|
125
134
|
this.runTimeoutMs = options.runTimeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
|
|
126
135
|
if (!(this.runTimeoutMs >= 0)) throw new TypeError("Scheduler: runTimeoutMs must be 0 or more");
|
|
136
|
+
this.timeZone = options.timeZone === undefined ? undefined : checkTimeZone(options.timeZone);
|
|
127
137
|
}
|
|
128
138
|
|
|
129
139
|
/** True after `start()` and before `stop()`. */
|
|
@@ -199,12 +209,12 @@ export class Scheduler {
|
|
|
199
209
|
let due = this.due.get(schedule.id);
|
|
200
210
|
if (due === undefined || due.version !== version) {
|
|
201
211
|
// A new or a changed schedule: its first time is after it was added, and after the start
|
|
202
|
-
due = { version, at: nextOf(schedule, Math.max(schedule.createdAt, this.startedAt) - 1) };
|
|
212
|
+
due = { version, at: nextOf(schedule, Math.max(schedule.createdAt, this.startedAt) - 1, this.timeZone) };
|
|
203
213
|
this.due.set(schedule.id, due);
|
|
204
214
|
}
|
|
205
215
|
if (due.at === undefined || due.at > now) continue;
|
|
206
216
|
// The times that passed give one run: the next time comes after now
|
|
207
|
-
due.at = nextOf(schedule, now);
|
|
217
|
+
due.at = nextOf(schedule, now, this.timeZone);
|
|
208
218
|
if (!this.launch(schedule, now)) void this.skip(schedule, now);
|
|
209
219
|
}
|
|
210
220
|
}
|