@stacksjs/scheduler 0.58.51 → 0.58.52

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