Skip to content

Commit a76e75f

Browse files
committed
fix: OR day-of-month and day-of-week when a field is a full-coverage range
Per crontab(5), when both day fields are restricted (neither contains a literal "*"), a job runs when either field matches. The criterion is the "*" character, not whether the value-set covers every value. getNextDateFrom decided "restricted?" by cardinality (Object.keys(dayOfMonth).length !== 31, dayOfWeek !== 7), which treats a full-coverage range such as "1-31", "0-6" or "*/1" as a wildcard even though it is not "*". As a result "0 0 12 1-31 * 1" fired only on Mondays instead of every day. cron-parser 5.6.1 and croniter 6.2.2 both return every day for that expression from 2026-06-01 (Jun 1, 2, 3, 4); node-cron returned only Mondays (Jun 1, 8, 15, 22). Capture whether each day field was a literal "*" at parse time and use those flags in the day-advance condition instead of the cardinality proxy.
1 parent 8b16b60 commit a76e75f

2 files changed

Lines changed: 46 additions & 8 deletions

File tree

src/time.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export class CronTime {
4141
private month: TimeUnitField<'month'> = {};
4242
private dayOfWeek: TimeUnitField<'dayOfWeek'> = {};
4343

44+
// whether the raw day-of-month / day-of-week field was a literal "*".
45+
// crontab(5) ORs the two day fields only when neither contains "*", so we
46+
// must track the literal wildcard rather than infer it from cardinality (a
47+
// full-coverage range like "1-31" has full cardinality yet is not "*").
48+
private dayOfMonthWildcard = false;
49+
private dayOfWeekWildcard = false;
50+
4451
constructor(
4552
source: CronJobParams['cronTime'],
4653
timeZone?: CronJobParams['timeZone'],
@@ -279,19 +286,19 @@ export class CronTime {
279286
continue;
280287
}
281288

289+
// crontab(5): when both day fields are restricted (neither is a
290+
// literal "*"), the command runs when EITHER matches (OR). A field
291+
// is restricted by the wildcard token, not by its cardinality, so a
292+
// full-coverage range like "1-31" still counts as restricted.
282293
if (
283294
(!(date.day in this.dayOfMonth) &&
284-
Object.keys(this.dayOfMonth).length !== 31 &&
295+
!this.dayOfMonthWildcard &&
285296
!(
286-
this._getWeekDay(date) in this.dayOfWeek &&
287-
Object.keys(this.dayOfWeek).length !== 7
297+
this._getWeekDay(date) in this.dayOfWeek && !this.dayOfWeekWildcard
288298
)) ||
289299
(!(this._getWeekDay(date) in this.dayOfWeek) &&
290-
Object.keys(this.dayOfWeek).length !== 7 &&
291-
!(
292-
date.day in this.dayOfMonth &&
293-
Object.keys(this.dayOfMonth).length !== 31
294-
))
300+
!this.dayOfWeekWildcard &&
301+
!(date.day in this.dayOfMonth && !this.dayOfMonthWildcard))
295302
) {
296303
date = date.plus({ days: 1 });
297304
date = date.set({ hour: 0, minute: 0, second: 0 });
@@ -501,6 +508,15 @@ export class CronTime {
501508
}
502509
});
503510

511+
// record whether this day field was a literal "*" before we expand it,
512+
// so the OR-semantics in getNextDateFrom can key off the wildcard token
513+
// rather than the resulting cardinality
514+
if (unit === 'dayOfMonth') {
515+
this.dayOfMonthWildcard = value.includes('*');
516+
} else if (unit === 'dayOfWeek') {
517+
this.dayOfWeekWildcard = value.includes('*');
518+
}
519+
504520
// "*" is a shortcut to [low-high] range for the field
505521
value = value.replace(RE_WILDCARDS, `${low}-${high}`);
506522

tests/crontime.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,4 +718,26 @@ describe('validateCronExpression', () => {
718718
expect(validation.error).toBeInstanceOf(CronError);
719719
});
720720
});
721+
722+
it('should OR day-of-month and day-of-week when one field is a full-coverage range', () => {
723+
// crontab(5): the union (OR) of the day fields applies only when both
724+
// fields are restricted, i.e. neither field contains a literal "*".
725+
// here "1-31" covers every day-of-month but is not "*", so dom and dow
726+
// (monday) are both restricted and must be OR'd, firing every day.
727+
const ct = new CronTime('0 0 12 1-31 * 1');
728+
const start = DateTime.fromISO('2026-06-01T00:00:00.000Z', {
729+
zone: 'utc'
730+
});
731+
732+
const fired: number[] = [];
733+
let cursor = start;
734+
for (let i = 0; i < 4; i++) {
735+
const next = ct.getNextDateFrom(cursor, 'utc');
736+
fired.push(next.day);
737+
cursor = next;
738+
}
739+
740+
// verified against cron-parser 5.6.1 and croniter 6.2.2: jun 1, 2, 3, 4.
741+
expect(fired).toEqual([1, 2, 3, 4]);
742+
});
721743
});

0 commit comments

Comments
 (0)