@stacksjs/scheduler 0.64.5 → 0.65.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/src/time.ts DELETED
@@ -1,719 +0,0 @@
1
- import type { Zone } from 'luxon'
2
- import { DateTime } from 'luxon'
3
-
4
- import {
5
- ALIASES,
6
- CONSTRAINTS,
7
- MONTH_CONSTRAINTS,
8
- PARSE_DEFAULTS,
9
- PRESETS,
10
- RE_RANGE,
11
- RE_WILDCARDS,
12
- TIME_UNITS,
13
- TIME_UNITS_LEN,
14
- TIME_UNITS_MAP,
15
- } from './constants'
16
- import { CronError, ExclusiveParametersError } from './errors'
17
- import type { CronJobParams, DayOfMonthRange, MonthRange, Ranges, TimeUnit, TimeUnitField } from './types/cron'
18
- import { getRecordKeys } from './utils'
19
-
20
- export class CronTime {
21
- source: string | DateTime
22
- timeZone?: string
23
- utcOffset?: number
24
- realDate = false
25
-
26
- private second: TimeUnitField<'second'> = {}
27
- private minute: TimeUnitField<'minute'> = {}
28
- private hour: TimeUnitField<'hour'> = {}
29
- private dayOfMonth: TimeUnitField<'dayOfMonth'> = {}
30
- private month: TimeUnitField<'month'> = {}
31
- private dayOfWeek: TimeUnitField<'dayOfWeek'> = {}
32
-
33
- constructor(source: CronJobParams['cronTime'], timeZone?: CronJobParams['timeZone'], utcOffset?: null)
34
- constructor(source: CronJobParams['cronTime'], timeZone?: null, utcOffset?: CronJobParams['utcOffset'])
35
- constructor(
36
- source: CronJobParams['cronTime'],
37
- timeZone?: CronJobParams['timeZone'],
38
- utcOffset?: CronJobParams['utcOffset'],
39
- ) {
40
- // runtime check for JS users
41
- if (timeZone != null && utcOffset != null) throw new ExclusiveParametersError('timeZone', 'utcOffset')
42
-
43
- if (timeZone) {
44
- const dt = DateTime.fromObject({}, { zone: timeZone })
45
- if (!dt.isValid) throw new CronError('Invalid timezone.')
46
-
47
- this.timeZone = timeZone
48
- }
49
-
50
- if (utcOffset != null) this.utcOffset = utcOffset
51
-
52
- if (source instanceof Date || source instanceof DateTime) {
53
- this.source = source instanceof Date ? DateTime.fromJSDate(source) : source
54
- this.realDate = true
55
- } else {
56
- this.source = source
57
- this._parse(this.source)
58
- this._verifyParse()
59
- }
60
- }
61
-
62
- private _getWeekDay(date: DateTime) {
63
- return date.weekday === 7 ? 0 : date.weekday
64
- }
65
-
66
- /**
67
- * Ensure that the syntax parsed correctly and correct the specified values if needed.
68
- */
69
- private _verifyParse() {
70
- const months = getRecordKeys(this.month)
71
- const daysOfMonth = getRecordKeys(this.dayOfMonth)
72
-
73
- let isOk = false
74
-
75
- /**
76
- * if a dayOfMonth is not found in all months, we only need to fix the last
77
- * wrong month to prevent infinite loop
78
- */
79
- let lastWrongMonth: MonthRange | null = null
80
- for (const m of months) {
81
- const con = MONTH_CONSTRAINTS[m]
82
-
83
- for (const day of daysOfMonth) {
84
- if (day <= con) isOk = true
85
- }
86
-
87
- if (!isOk) {
88
- // save the month in order to be fixed if all months fails (infinite loop)
89
- lastWrongMonth = m
90
- console.warn(`Month '${m}' is limited to '${con}' days.`)
91
- }
92
- }
93
-
94
- // infinite loop detected (dayOfMonth is not found in all months)
95
- if (!isOk && lastWrongMonth !== null) {
96
- const notOkCon = MONTH_CONSTRAINTS[lastWrongMonth]
97
- for (const notOkDay of daysOfMonth) {
98
- if (notOkDay > notOkCon) {
99
- delete this.dayOfMonth[notOkDay]
100
- const fixedDay = (notOkDay % notOkCon) as DayOfMonthRange
101
- this.dayOfMonth[fixedDay] = true
102
- }
103
- }
104
- }
105
- }
106
-
107
- /**
108
- * Calculate the "next" scheduled time
109
- */
110
- sendAt(): DateTime
111
- sendAt(i: number): DateTime[]
112
- sendAt(i?: number): DateTime | DateTime[] {
113
- let date = this.realDate && this.source instanceof DateTime ? this.source : DateTime.local()
114
- if (this.timeZone) date = date.setZone(this.timeZone)
115
-
116
- if (this.utcOffset !== undefined) {
117
- const sign = this.utcOffset < 0 ? '-' : '+'
118
-
119
- const offsetHours = Math.trunc(this.utcOffset / 60)
120
- const offsetHoursStr = String(Math.abs(offsetHours)).padStart(2, '0')
121
-
122
- const offsetMins = Math.abs(this.utcOffset - offsetHours * 60)
123
- const offsetMinsStr = String(offsetMins).padStart(2, '0')
124
-
125
- const utcZone = `UTC${sign}${offsetHoursStr}:${offsetMinsStr}`
126
-
127
- date = date.setZone(utcZone)
128
-
129
- if (!date.isValid) throw new CronError('ERROR: You specified an invalid UTC offset.')
130
- }
131
-
132
- if (this.realDate) {
133
- if (DateTime.local() > date) throw new CronError('WARNING: Date in past. Will never be fired.')
134
-
135
- return date
136
- }
137
-
138
- if (i === undefined || Number.isNaN(i) || i < 0) {
139
- // just get the next scheduled time
140
- return this.getNextDateFrom(date)
141
- }
142
-
143
- // return the next schedule times
144
- const dates: DateTime[] = []
145
- for (; i > 0; i--) {
146
- date = this.getNextDateFrom(date)
147
- dates.push(date)
148
- }
149
-
150
- return dates
151
- }
152
-
153
- /**
154
- * Get the number of milliseconds in the future at which to fire our callbacks.
155
- */
156
- getTimeout() {
157
- return Math.max(-1, this.sendAt().toMillis() - DateTime.local().toMillis())
158
- }
159
-
160
- /**
161
- * writes out a cron string
162
- */
163
- toString() {
164
- return this.toJSON().join(' ')
165
- }
166
-
167
- /**
168
- * Json representation of the parsed cron syntax.
169
- */
170
- toJSON() {
171
- return TIME_UNITS.map((unit) => {
172
- return this._wcOrAll(unit)
173
- })
174
- }
175
-
176
- /**
177
- * Get next date matching the specified cron time.
178
- *
179
- * Algorithm:
180
- * - Start with a start date and a parsed crontime.
181
- * - Loop until 5 seconds have passed, or we found the next date.
182
- * - Within the loop:
183
- * - If it took longer than 5 seconds to select a date, throw an exception.
184
- * - Find the next month to run at.
185
- * - Find the next day of the month to run at.
186
- * - Find the next day of the week to run at.
187
- * - Find the next hour to run at.
188
- * - Find the next minute to run at.
189
- * - Find the next second to run at.
190
- * - Check that the chosen time does not equal the current execution.
191
- * - Return the selected date object.
192
- */
193
- getNextDateFrom(start: Date | DateTime, timeZone?: string | Zone) {
194
- if (start instanceof Date) start = DateTime.fromJSDate(start)
195
-
196
- let date = start
197
- const firstDate = start.toMillis()
198
- if (timeZone) date = date.setZone(timeZone)
199
-
200
- if (!this.realDate) {
201
- if (date.millisecond > 0) date = date.set({ millisecond: 0, second: date.second + 1 })
202
- }
203
-
204
- if (!date.isValid) throw new CronError('ERROR: You specified an invalid date.')
205
-
206
- /**
207
- * maximum match interval is 8 years:
208
- * crontab has '* * 29 2 *' and we are on 1 March 2096:
209
- * next matching time will be 29 February 2104
210
- * source: https://github.com/cronie-crond/cronie/blob/0d669551680f733a4bdd6bab082a0b3d6d7f089c/src/cronnext.c#L401-L403
211
- */
212
- const maxMatch = DateTime.now().plus({ years: 8 })
213
-
214
- // determine next date
215
- while (true) {
216
- const diff = date.toMillis() - start.toMillis()
217
-
218
- // hard stop if the current date is after the maximum match interval
219
- if (date > maxMatch) {
220
- throw new CronError(
221
- `Something went wrong. No execution date was found in the next 8 years.
222
- Please provide the following string if you would like to help debug:
223
- Time Zone: ${timeZone?.toString() ?? '""'} - Cron String: ${this.source.toString()} - UTC offset: ${
224
- date.offset
225
- } - current Date: ${DateTime.local().toString()}`,
226
- )
227
- }
228
-
229
- if (!(date.month in this.month) && Object.keys(this.month).length !== 12) {
230
- date = date.plus({ months: 1 })
231
- date = date.set({ day: 1, hour: 0, minute: 0, second: 0 })
232
-
233
- if (this._forwardDSTJump(0, 0, date)) {
234
- const [isDone, newDate] = this._findPreviousDSTJump(date)
235
- date = newDate
236
- if (isDone) break
237
- }
238
- continue
239
- }
240
-
241
- if (
242
- !(date.day in this.dayOfMonth) &&
243
- Object.keys(this.dayOfMonth).length !== 31 &&
244
- !(this._getWeekDay(date) in this.dayOfWeek && Object.keys(this.dayOfWeek).length !== 7)
245
- ) {
246
- date = date.plus({ days: 1 })
247
- date = date.set({ hour: 0, minute: 0, second: 0 })
248
-
249
- if (this._forwardDSTJump(0, 0, date)) {
250
- const [isDone, newDate] = this._findPreviousDSTJump(date)
251
- date = newDate
252
- if (isDone) break
253
- }
254
- continue
255
- }
256
-
257
- if (
258
- !(this._getWeekDay(date) in this.dayOfWeek) &&
259
- Object.keys(this.dayOfWeek).length !== 7 &&
260
- !(date.day in this.dayOfMonth && Object.keys(this.dayOfMonth).length !== 31)
261
- ) {
262
- date = date.plus({ days: 1 })
263
- date = date.set({ hour: 0, minute: 0, second: 0 })
264
- if (this._forwardDSTJump(0, 0, date)) {
265
- const [isDone, newDate] = this._findPreviousDSTJump(date)
266
- date = newDate
267
- if (isDone) break
268
- }
269
- continue
270
- }
271
-
272
- if (!(date.hour in this.hour) && Object.keys(this.hour).length !== 24) {
273
- const expectedHour = date.hour === 23 && diff > 86400000 ? 0 : date.hour + 1
274
- const expectedMinute = date.minute // expect no change.
275
-
276
- date = date.set({ hour: expectedHour })
277
- date = date.set({ minute: 0, second: 0 })
278
-
279
- // When this is the case, Asking luxon to go forward by 1 hour actually made us go forward by more hours...
280
- // This indicates that somewhere between these two time points, a forward DST adjustment has happened.
281
- // When this happens, the job should be scheduled to execute as though the time has come when the jump is made.
282
- // Therefore, the job should be scheduled on the first tick after the forward jump.
283
- if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
284
- const [isDone, newDate] = this._findPreviousDSTJump(date)
285
- date = newDate
286
- if (isDone) break
287
- }
288
- // backwards jumps do not seem to have any problems (i.e. double activations),
289
- // so they need not be handled in a similar way.
290
-
291
- continue
292
- }
293
-
294
- if (!(date.minute in this.minute) && Object.keys(this.minute).length !== 60) {
295
- const expectedMinute = date.minute === 59 && diff > 3600000 ? 0 : date.minute + 1
296
- const expectedHour = date.hour + (expectedMinute === 60 ? 1 : 0)
297
-
298
- date = date.set({ minute: expectedMinute })
299
- date = date.set({ second: 0 })
300
-
301
- // Same case as with hours: DST forward jump.
302
- // This must be accounted for if a minute increment pushed us to a jumping point.
303
- if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
304
- const [isDone, newDate] = this._findPreviousDSTJump(date)
305
- date = newDate
306
- if (isDone) break
307
- }
308
-
309
- continue
310
- }
311
-
312
- if (!(date.second in this.second) && Object.keys(this.second).length !== 60) {
313
- const expectedSecond = date.second === 59 && diff > 60000 ? 0 : date.second + 1
314
- const expectedMinute = date.minute + (expectedSecond === 60 ? 1 : 0)
315
- const expectedHour = date.hour + (expectedMinute === 60 ? 1 : 0)
316
-
317
- date = date.set({ second: expectedSecond })
318
-
319
- // Seconds can cause it too, imagine 21:59:59 -> 23:00:00.
320
- if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
321
- const [isDone, newDate] = this._findPreviousDSTJump(date)
322
- date = newDate
323
- if (isDone) break
324
- }
325
-
326
- continue
327
- }
328
-
329
- if (date.toMillis() === firstDate) {
330
- const expectedSecond = date.second + 1
331
- const expectedMinute = date.minute + (expectedSecond === 60 ? 1 : 0)
332
- const expectedHour = date.hour + (expectedMinute === 60 ? 1 : 0)
333
-
334
- date = date.set({ second: expectedSecond })
335
-
336
- // Same as always.
337
- if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
338
- const [isDone, newDate] = this._findPreviousDSTJump(date)
339
- date = newDate
340
- if (isDone) break
341
- }
342
-
343
- continue
344
- }
345
-
346
- break
347
- }
348
-
349
- return date
350
- }
351
-
352
- /**
353
- * Search backwards in time 1 minute at a time, to detect a DST forward jump.
354
- * When the jump is found, the range of the jump is investigated to check for acceptable cron times.
355
- *
356
- * A pair is returned, whose first is a boolean representing if an acceptable time was found inside the jump,
357
- * and whose second is a DateTime representing the first millisecond after the jump.
358
- *
359
- * The input date is expected to be decently close to a DST jump.
360
- * Up to a day in the past is checked before an error is thrown.
361
- * @param date
362
- * @return [boolean, DateTime]
363
- */
364
- private _findPreviousDSTJump(date: DateTime): [boolean, DateTime] {
365
- let expectedMinute: number
366
- let expectedHour: number
367
- let actualMinute: number
368
- let actualHour: number
369
-
370
- /** @type DateTime */
371
- let maybeJumpingPoint = date
372
-
373
- // representing one day of backwards checking. If this is hit, the input must be wrong.
374
- const iterationLimit = 60 * 24
375
- let iteration = 0
376
- do {
377
- if (++iteration > iterationLimit) {
378
- throw new CronError(
379
- `ERROR: This DST checking related function assumes the input DateTime (${
380
- date.toISO() ?? date.toMillis()
381
- }) is within 24 hours of a DST jump.`,
382
- )
383
- }
384
-
385
- expectedMinute = maybeJumpingPoint.minute - 1
386
- expectedHour = maybeJumpingPoint.hour
387
-
388
- if (expectedMinute < 0) {
389
- expectedMinute += 60
390
- expectedHour = (expectedHour + 24 - 1) % 24 // Subtract 1 hour, but we must account for the -1 case.
391
- }
392
-
393
- maybeJumpingPoint = maybeJumpingPoint.minus({ minute: 1 })
394
-
395
- actualMinute = maybeJumpingPoint.minute
396
- actualHour = maybeJumpingPoint.hour
397
- } while (expectedMinute === actualMinute && expectedHour === actualHour)
398
-
399
- // Setting the seconds and milliseconds to zero is necessary for two reasons:
400
- // Firstly, the range checking function needs the earliest moment after the jump.
401
- // Secondly, this DateTime may be used for scheduling jobs, if there existed a job in the skipped range.
402
- const afterJumpingPoint = maybeJumpingPoint
403
- .plus({ minute: 1 }) // back to the first minute _after_ the jump
404
- .set({ second: 0, millisecond: 0 })
405
-
406
- // Get the lower bound of the range to check as well. This only has to be accurate down to minutes.
407
- const beforeJumpingPoint = afterJumpingPoint.minus({ second: 1 })
408
-
409
- if (date.month + 1 in this.month && date.day in this.dayOfMonth && this._getWeekDay(date) in this.dayOfWeek) {
410
- return [this._checkTimeInSkippedRange(beforeJumpingPoint, afterJumpingPoint), afterJumpingPoint]
411
- }
412
-
413
- // no valid time in the range for sure, units that didn't change from the skip mismatch.
414
- return [false, afterJumpingPoint]
415
- }
416
-
417
- /**
418
- * Given 2 DateTimes, which represent 1 second before and immediately after a DST forward jump,
419
- * checks if a time in the skipped range would have been a valid CronJob time.
420
- *
421
- * Could technically work with just one of these values, extracting the other by adding or subtracting seconds.
422
- * However, this couples the input DateTime to actually being tied to a DST jump,
423
- * which would make the function harder to test.
424
- * This way the logic just tests a range of minutes and hours, regardless if there are skipped time points underneath.
425
- *
426
- * Assumes the DST jump started no earlier than 0:00 and jumped forward by at least 1 minute, to at most 23:59.
427
- * i.e. The day is assumed constant, but the jump is not assumed to be an hour long.
428
- * Empirically, it is almost always one hour, but very, very rarely 30 minutes.
429
- *
430
- * Assumes dayOfWeek, dayOfMonth and month match all match, so only the hours, minutes and seconds are to be checked.
431
- * @param {DateTime} beforeJumpingPoint
432
- * @param {DateTime} afterJumpingPoint
433
- * @returns {boolean} True if a valid CronJob time exists within the skipped DST range, false otherwise.
434
- */
435
- private _checkTimeInSkippedRange(beforeJumpingPoint: DateTime, afterJumpingPoint: DateTime) {
436
- // start by getting the first minute & hour inside the skipped range.
437
- const startingMinute = (beforeJumpingPoint.minute + 1) % 60
438
- const startingHour = (beforeJumpingPoint.hour + (startingMinute === 0 ? 1 : 0)) % 24
439
-
440
- const hourRangeSize = afterJumpingPoint.hour - startingHour + 1
441
- const isHourJump = startingMinute === 0 && afterJumpingPoint.minute === 0
442
-
443
- // There exist DST jumps other than 1 hour long, and the function is built to deal with it.
444
- // It may be overkill to assume some cases, but it shouldn't cost much at runtime.
445
- // https://en.wikipedia.org/wiki/Daylight_saving_time_by_country
446
- if (hourRangeSize === 2 && isHourJump) {
447
- // Exact 1 hour jump, most common real-world case.
448
- // There is no need to check minutes and seconds, as any value would suffice.
449
- return startingHour in this.hour
450
- }
451
-
452
- if (hourRangeSize === 1) {
453
- // less than 1 hour jump, rare but does exist.
454
- return (
455
- startingHour in this.hour && this._checkTimeInSkippedRangeSingleHour(startingMinute, afterJumpingPoint.minute)
456
- )
457
- }
458
-
459
- // non-round or multi-hour jump. (does not exist in the real world at the time of writing)
460
- return this._checkTimeInSkippedRangeMultiHour(
461
- startingHour,
462
- startingMinute,
463
- afterJumpingPoint.hour,
464
- afterJumpingPoint.minute,
465
- )
466
- }
467
-
468
- /**
469
- * Component of checking if a CronJob time existed in a DateTime range skipped by DST.
470
- * This subroutine makes a further assumption that the skipped range is fully contained in one hour,
471
- * and that all other larger units are valid for the job.
472
- *
473
- * for example a jump from 02:00:00 to 02:30:00, but not from 02:00:00 to 03:00:00.
474
- * @see _checkTimeInSkippedRange
475
- *
476
- * This is done by checking if any minute in startMinute - endMinute is valid, excluding endMinute.
477
- * For endMinute, there is only a match if the 0th second is a valid time.
478
- */
479
- private _checkTimeInSkippedRangeSingleHour(startMinute: number, endMinute: number) {
480
- for (let minute = startMinute; minute < endMinute; ++minute) {
481
- if (minute in this.minute) return true
482
- }
483
-
484
- // Unless the very last second of the jump matched, there is no match.
485
- return endMinute in this.minute && 0 in this.second
486
- }
487
-
488
- /**
489
- * Component of checking if a CronJob time existed in a DateTime range skipped by DST.
490
- * This subroutine assumes the jump touches at least 2 hours, but the jump does not necessarily fully contain these hours.
491
- *
492
- * @see _checkTimeInSkippedRange
493
- *
494
- * This is done by defining the minutes to check for the first and last hour,
495
- * and checking all 60 minutes for any hours in between them.
496
- *
497
- * If any hour x minute combination is a valid time, true is returned.
498
- * The endMinute x endHour combination is only checked with the 0th second, since the rest would be out of the range.
499
- *
500
- * @param startHour {number}
501
- * @param startMinute {number}
502
- * @param endHour {number}
503
- * @param endMinute {number}
504
- */
505
- private _checkTimeInSkippedRangeMultiHour(
506
- startHour: number,
507
- startMinute: number,
508
- endHour: number,
509
- endMinute: number,
510
- ) {
511
- if (startHour >= endHour) {
512
- throw new CronError(
513
- `ERROR: This DST checking related function assumes the forward jump starting hour (${startHour}) is less than the end hour (${endHour})`,
514
- )
515
- }
516
-
517
- /** @type number[] */
518
- const firstHourMinuteRange = Array.from({ length: 60 - startMinute }, (_, k) => startMinute + k)
519
- /** @type {number[]} The final minute is not contained on purpose. Every minute in this range represents one for which any second is valid. */
520
- const lastHourMinuteRange = Array.from({ length: endMinute }, (_, k) => k)
521
- /** @type number[] */
522
- const middleHourMinuteRange = Array.from({ length: 60 }, (_, k) => k)
523
-
524
- /** @type (number) => number[] */
525
- const selectRange = (forHour: number) => {
526
- if (forHour === startHour) return firstHourMinuteRange
527
- if (forHour === endHour) return lastHourMinuteRange
528
- return middleHourMinuteRange
529
- }
530
-
531
- // Include the endHour: Selecting the right range still ensures no values outside the skip are checked.
532
- for (let hour = startHour; hour <= endHour; ++hour) {
533
- if (!(hour in this.hour)) continue
534
-
535
- // The hour matches, so if the minute is in the range, we have a match!
536
- const usingRange = selectRange(hour)
537
-
538
- for (const minute of usingRange) {
539
- // All minutes in any of the selected ranges represent minutes which are fully contained in the jump,
540
- // So we need not check the seconds. If the minute is in there, it is a match.
541
- if (minute in this.minute) return true
542
- }
543
- }
544
-
545
- // The endMinute of the endHour was not checked in the loop, because only the 0th second of it is in the range.
546
- // Arriving here means no match was found yet, but this final check may turn up as a match.
547
- return endHour in this.hour && endMinute in this.minute && 0 in this.second
548
- }
549
-
550
- /**
551
- * Given expected and actual hours and minutes, report if a DST forward jump occurred.
552
- *
553
- * This is the case when the expected is smaller than the acutal.
554
- *
555
- * It is not sufficient to check only hours, because some parts of the world apply DST by shifting in minutes.
556
- * Better to account for it by checking minutes too, before an Australian of Lord Howe Island call us.
557
- * @param expectedHour
558
- * @param expectedMinute
559
- * @param {DateTime} actualDate
560
- */
561
- private _forwardDSTJump(expectedHour: number, expectedMinute: number, actualDate: DateTime) {
562
- const actualHour = actualDate.hour
563
- const actualMinute = actualDate.minute
564
-
565
- const didHoursJumped = expectedHour % 24 < actualHour
566
- const didMinutesJumped = expectedMinute % 60 < actualMinute
567
-
568
- return didHoursJumped || didMinutesJumped
569
- }
570
-
571
- /**
572
- * wildcard, or all params in array (for to string)
573
- */
574
- private _wcOrAll(unit: TimeUnit) {
575
- if (this._hasAll(unit)) return '*'
576
-
577
- const all = []
578
- for (const time in this[unit]) all.push(time)
579
-
580
- return all.join(',')
581
- }
582
-
583
- private _hasAll(unit: TimeUnit) {
584
- const constraints = CONSTRAINTS[unit]
585
- const low = constraints[0]
586
- const high = unit === TIME_UNITS_MAP.DAY_OF_WEEK ? constraints[1] - 1 : constraints[1]
587
-
588
- for (let i = low, n = high; i < n; i++) {
589
- if (!(i in this[unit])) return false
590
- }
591
-
592
- return true
593
- }
594
-
595
- /**
596
- * Parse the cron syntax into something useful for selecting the next execution time.
597
- *
598
- * Algorithm:
599
- * - Replace preset
600
- * - Replace aliases in the source.
601
- * - Trim string and split for processing.
602
- * - Loop over split options (ms -> month):
603
- * - Get the value (or default) in the current position.
604
- * - Parse the value.
605
- */
606
- private _parse(source: string) {
607
- source = source.toLowerCase()
608
-
609
- if (Object.keys(PRESETS).includes(source)) source = PRESETS[source as keyof typeof PRESETS]
610
-
611
- source = source.replace(/[a-z]{1,3}/gi, (alias: string) => {
612
- if (Object.keys(ALIASES).includes(alias)) return ALIASES[alias as keyof typeof ALIASES].toString()
613
-
614
- throw new CronError(`Unknown alias: ${alias}`)
615
- })
616
-
617
- const units = source.trim().split(/\s+/)
618
-
619
- // seconds are optional
620
- if (units.length < TIME_UNITS_LEN - 1) throw new CronError('Too few fields')
621
-
622
- if (units.length > TIME_UNITS_LEN) throw new CronError('Too many fields')
623
-
624
- const unitsLen = units.length
625
- for (const unit of TIME_UNITS) {
626
- const i = TIME_UNITS.indexOf(unit)
627
- // If the split source string doesn't contain all digits,
628
- // assume defaults for first n missing digits.
629
- // This adds support for 5-digit standard cron syntax
630
- const cur = units[i - (TIME_UNITS_LEN - unitsLen)] ?? PARSE_DEFAULTS[unit]
631
- this._parseField(cur, unit)
632
- }
633
- }
634
-
635
- /**
636
- * Parse individual field from the cron syntax provided.
637
- *
638
- * Algorithm:
639
- * - Split field by commas aand check for wildcards to ensure proper user.
640
- * - Replace wildcard values with <low>-<high> boundaries.
641
- * - Split field by commas and then iterate over ranges inside field.
642
- * - If range matches pattern then map over matches using replace (to parse the range by the regex pattern)
643
- * - Starting with the lower bounds of the range iterate by step up to the upper bounds and toggle the CronTime field value flag on.
644
- */
645
-
646
- private _parseField(value: string, unit: TimeUnit) {
647
- const typeObj = this[unit] as TimeUnitField<typeof unit>
648
- let pointer: Ranges[typeof unit]
649
-
650
- const constraints = CONSTRAINTS[unit]
651
- const low = constraints[0]
652
- const high = constraints[1]
653
-
654
- const fields = value.split(',')
655
- fields.forEach((field) => {
656
- const wildcardIndex = field.indexOf('*')
657
- if (wildcardIndex !== -1 && wildcardIndex !== 0) {
658
- throw new CronError(`Field (${field}) has an invalid wildcard expression`)
659
- }
660
- })
661
-
662
- // "*" is a shortcut to [low-high] range for the field
663
- value = value.replace(RE_WILDCARDS, `${low}-${high}`)
664
-
665
- // commas separate information, so split based on those
666
- const allRanges = value.split(',')
667
-
668
- for (const range of allRanges) {
669
- const match = [...range.matchAll(RE_RANGE)][0]
670
- if (match?.[1] !== undefined) {
671
- const [, mLower, mUpper, mStep] = match
672
- let lower = Number.parseInt(mLower, 10)
673
- let upper = mUpper !== undefined ? Number.parseInt(mUpper, 10) : undefined
674
-
675
- const wasStepDefined = mStep !== undefined
676
- const step = Number.parseInt(mStep ?? '1', 10)
677
- if (step === 0) throw new CronError(`Field (${unit}) has a step of zero`)
678
-
679
- if (upper !== undefined && lower > upper) throw new CronError(`Field (${unit}) has an invalid range`)
680
-
681
- const isOutOfRange =
682
- lower < low || (upper !== undefined && upper > high) || (upper === undefined && lower > high)
683
-
684
- if (isOutOfRange) throw new CronError(`Field value (${value}) is out of range`)
685
-
686
- // Positive integer higher than constraints[0]
687
- lower = Math.min(Math.max(low, ~~Math.abs(lower)), high)
688
-
689
- // Positive integer lower than constraints[1]
690
- if (upper !== undefined) {
691
- upper = Math.min(high, ~~Math.abs(upper))
692
- } else {
693
- // If step is provided, the default upper range is the highest value
694
- upper = wasStepDefined ? high : lower
695
- }
696
-
697
- // Count from the lower barrier to the upper
698
- // forcing type cast here since we checked above that
699
- // we are between constraint bounds
700
- pointer = lower as typeof pointer
701
-
702
- do {
703
- typeObj[pointer] = true // mutates the field objects values inside CronTime
704
- pointer += step
705
- } while (pointer <= upper)
706
-
707
- // merge day 7 into day 0 (both Sunday), and remove day 7
708
- // since we work with day-of-week 0-6 under the hood
709
- if (unit === 'dayOfWeek') {
710
- if (!typeObj[0] && !!typeObj[7]) typeObj[0] = typeObj[7]
711
- // delete typeObj[7]
712
- typeObj[7] = undefined
713
- }
714
- } else {
715
- throw new CronError(`Field (${unit}) cannot be parsed`)
716
- }
717
- }
718
- }
719
- }