Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions src/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export class CronTime {
private month: TimeUnitField<'month'> = {};
private dayOfWeek: TimeUnitField<'dayOfWeek'> = {};

// whether the raw day-of-month / day-of-week field was a literal "*".
// crontab(5) ORs the two day fields only when neither contains "*", so we
// must track the literal wildcard rather than infer it from cardinality (a
// full-coverage range like "1-31" has full cardinality yet is not "*").
private dayOfMonthWildcard = false;
private dayOfWeekWildcard = false;

constructor(
source: CronJobParams['cronTime'],
timeZone?: CronJobParams['timeZone'],
Expand Down Expand Up @@ -279,19 +286,19 @@ export class CronTime {
continue;
}

// crontab(5): when both day fields are restricted (neither is a
// literal "*"), the command runs when EITHER matches (OR). A field
// is restricted by the wildcard token, not by its cardinality, so a
// full-coverage range like "1-31" still counts as restricted.
if (
(!(date.day in this.dayOfMonth) &&
Object.keys(this.dayOfMonth).length !== 31 &&
!this.dayOfMonthWildcard &&
!(
this._getWeekDay(date) in this.dayOfWeek &&
Object.keys(this.dayOfWeek).length !== 7
this._getWeekDay(date) in this.dayOfWeek && !this.dayOfWeekWildcard
)) ||
(!(this._getWeekDay(date) in this.dayOfWeek) &&
Object.keys(this.dayOfWeek).length !== 7 &&
!(
date.day in this.dayOfMonth &&
Object.keys(this.dayOfMonth).length !== 31
))
!this.dayOfWeekWildcard &&
!(date.day in this.dayOfMonth && !this.dayOfMonthWildcard))
) {
date = date.plus({ days: 1 });
date = date.set({ hour: 0, minute: 0, second: 0 });
Expand Down Expand Up @@ -501,6 +508,15 @@ export class CronTime {
}
});

// record whether this day field was a literal "*" before we expand it,
// so the OR-semantics in getNextDateFrom can key off the wildcard token
// rather than the resulting cardinality
if (unit === 'dayOfMonth') {
this.dayOfMonthWildcard = value.includes('*');
} else if (unit === 'dayOfWeek') {
this.dayOfWeekWildcard = value.includes('*');
}

// "*" is a shortcut to [low-high] range for the field
value = value.replace(RE_WILDCARDS, `${low}-${high}`);

Expand Down
22 changes: 22 additions & 0 deletions tests/crontime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,4 +718,26 @@ describe('validateCronExpression', () => {
expect(validation.error).toBeInstanceOf(CronError);
});
});

it('should OR day-of-month and day-of-week when one field is a full-coverage range', () => {
// crontab(5): the union (OR) of the day fields applies only when both
// fields are restricted, i.e. neither field contains a literal "*".
// here "1-31" covers every day-of-month but is not "*", so dom and dow
// (monday) are both restricted and must be OR'd, firing every day.
const ct = new CronTime('0 0 12 1-31 * 1');
const start = DateTime.fromISO('2026-06-01T00:00:00.000Z', {
zone: 'utc'
});

const fired: number[] = [];
let cursor = start;
for (let i = 0; i < 4; i++) {
const next = ct.getNextDateFrom(cursor, 'utc');
fired.push(next.day);
cursor = next;
}

// verified against cron-parser 5.6.1 and croniter 6.2.2: jun 1, 2, 3, 4.
expect(fired).toEqual([1, 2, 3, 4]);
});
});
Loading