blob: 710bfb7949f29b7a1976c8c465699e55badf9852 [file]
export const CRON_COMPATIBILITY_PROFILES = ['plan-reminder-v1', 'automation-v1'] as const;
export type CronCompatibilityProfile = (typeof CRON_COMPATIBILITY_PROFILES)[number];
export type CronFieldName = 'minute' | 'hour' | 'day-of-month' | 'month' | 'day-of-week';
export type CronCompileErrorCode =
| 'invalid_field_count'
| 'unsupported_syntax'
| 'empty_list_item'
| 'invalid_step'
| 'invalid_range'
| 'reversed_range'
| 'invalid_integer'
| 'out_of_range'
| 'empty_field'
| 'unsatisfiable';
export interface CronCompileError {
readonly code: CronCompileErrorCode;
readonly field?: CronFieldName;
readonly min?: number;
readonly max?: number;
}
export interface CronSearchBounds {
/** Inclusive lower bound. The result is still strictly after `after`. */
readonly notBefore?: number;
/** Inclusive absolute upper bound. */
readonly notAfter?: number;
}
/**
* An opaque, validated five-field cron expression.
*
* Field sets and calendar-matching details intentionally stay private so a
* caller cannot create another interpretation of the grammar.
*/
export interface CompiledCronExpression {
/**
* Find the next whole-minute occurrence in the host's local timezone.
* Returns null when the bounded search contains no matching instant.
*/
nextAfter(after: number, bounds?: CronSearchBounds): number | null;
}
export type CompileCronExpressionResult =
| { readonly ok: true; readonly value: CompiledCronExpression }
| { readonly ok: false; readonly error: CronCompileError };
interface CronFieldSpec {
readonly name: CronFieldName;
readonly min: number;
readonly max: number;
readonly aliases?: Readonly<Record<string, number>>;
readonly normalizeSunday?: boolean;
}
interface CronCompatibilityPolicy {
readonly fieldSeparator: 'single-space' | 'ascii-whitespace';
readonly allowAliases: boolean;
readonly legacyTokenCoercion: boolean;
readonly singleValueStepExtendsToMax: boolean;
readonly limitStepToFieldWidth: boolean;
readonly wildcardMode: 'any-star-base' | 'literal-star';
readonly ignoreBareStarInList: boolean;
readonly allowEmptyMatchSet: boolean;
readonly rejectImpossibleDates: boolean;
readonly nextMinuteRounding: 'floor' | 'truncate';
}
interface ParsedCronField {
readonly wildcard: boolean;
readonly values: ReadonlySet<number>;
/** Values admitted by the legacy validator before matcher coercion. */
readonly validationValues: ReadonlySet<number>;
readonly validationWildcard: boolean;
}
interface ParseCronFieldOptions {
/** Preserve the retired standalone matcher's skip-invalid, no-validation contract. */
readonly matcherOnly?: boolean;
/** Candidate values to project instead of the field's normal integer domain. */
readonly candidates?: readonly number[];
}
interface ParsedCronExpression {
readonly minute: ParsedCronField;
readonly hour: ParsedCronField;
readonly dayOfMonth: ParsedCronField;
readonly month: ParsedCronField;
readonly dayOfWeek: ParsedCronField;
}
type CronParseResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: CronCompileError };
const MINUTE_MS = 60_000;
const MINUTES_PER_DAY = 24 * 60;
/**
* A sparse valid expression such as Feb 29 can be eight years away when a
* century year is not divisible by 400 (2096 -> 2104). This bound covers that
* maximum gap while still making an impossible expression terminate.
*/
const MAX_SEARCH_MINUTES = 8 * 366 * MINUTES_PER_DAY;
const MONTH_ALIASES: Readonly<Record<string, number>> = Object.freeze({
jan: 1,
feb: 2,
mar: 3,
apr: 4,
may: 5,
jun: 6,
jul: 7,
aug: 8,
sep: 9,
oct: 10,
nov: 11,
dec: 12,
});
const DAY_OF_WEEK_ALIASES: Readonly<Record<string, number>> = Object.freeze({
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
});
const FIELD_SPECS = Object.freeze({
minute: { name: 'minute', min: 0, max: 59 },
hour: { name: 'hour', min: 0, max: 23 },
dayOfMonth: { name: 'day-of-month', min: 1, max: 31 },
month: { name: 'month', min: 1, max: 12, aliases: MONTH_ALIASES },
dayOfWeek: {
name: 'day-of-week',
min: 0,
max: 7,
aliases: DAY_OF_WEEK_ALIASES,
normalizeSunday: true,
},
} satisfies Record<string, CronFieldSpec>);
const PROFILE_POLICIES: Readonly<Record<CronCompatibilityProfile, CronCompatibilityPolicy>> =
Object.freeze({
'plan-reminder-v1': {
fieldSeparator: 'single-space',
allowAliases: false,
legacyTokenCoercion: false,
singleValueStepExtendsToMax: false,
limitStepToFieldWidth: true,
wildcardMode: 'any-star-base',
ignoreBareStarInList: false,
allowEmptyMatchSet: false,
rejectImpossibleDates: false,
nextMinuteRounding: 'floor',
},
'automation-v1': {
fieldSeparator: 'ascii-whitespace',
allowAliases: true,
// Existing persisted Automation schedules inherit parseInt-prefix
// coercion and ignored extra slash/range segments from the retired parser.
legacyTokenCoercion: true,
singleValueStepExtendsToMax: true,
limitStepToFieldWidth: false,
wildcardMode: 'literal-star',
// The legacy Automation matcher ignored a bare `*` when it appeared as
// one item in a list (`*,15` matched only 15). Keep that persisted-input
// behavior while removing the second parser that caused it.
ignoreBareStarInList: true,
// A legacy range could pass validation but contribute no matches because
// the old matcher used Number where validation used parseInt. Preserve
// that effective empty set, especially for Vixie DOM/DOW OR schedules.
allowEmptyMatchSet: true,
rejectImpossibleDates: true,
nextMinuteRounding: 'truncate',
},
});
// Leap-year maxima; used only for the impossible-date fast path.
const MAX_DAYS_IN_MONTH = Object.freeze([31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]);
export function compileCronExpression(
expression: string,
options: { readonly profile: CronCompatibilityProfile },
): CompileCronExpressionResult {
const policy = PROFILE_POLICIES[options.profile];
const parts =
policy.fieldSeparator === 'single-space'
? expression.split(' ')
: expression.trim().split(/\s+/);
if (parts.length !== 5) return parseError('invalid_field_count');
const minute = parseCronField(parts[0] ?? '', FIELD_SPECS.minute, policy);
if (!minute.ok) return minute;
const hour = parseCronField(parts[1] ?? '', FIELD_SPECS.hour, policy);
if (!hour.ok) return hour;
const dayOfMonth = parseCronField(parts[2] ?? '', FIELD_SPECS.dayOfMonth, policy);
if (!dayOfMonth.ok) return dayOfMonth;
const month = parseCronField(parts[3] ?? '', FIELD_SPECS.month, policy);
if (!month.ok) return month;
const dayOfWeek = parseCronField(parts[4] ?? '', FIELD_SPECS.dayOfWeek, policy);
if (!dayOfWeek.ok) return dayOfWeek;
const parsed: ParsedCronExpression = {
minute: minute.value,
hour: hour.value,
dayOfMonth: dayOfMonth.value,
month: month.value,
dayOfWeek: dayOfWeek.value,
};
if (policy.rejectImpossibleDates && hasImpossibleCalendarDate(parsed)) {
return parseError('unsatisfiable', 'day-of-month');
}
return {
ok: true,
value: Object.freeze({
nextAfter(after: number, bounds?: CronSearchBounds): number | null {
return nextCronOccurrence(parsed, after, bounds, policy.nextMinuteRounding);
},
}),
};
}
/**
* Compatibility entry for the existing `@maka/runtime` export.
*
* Fields admitted by the full-expression compiler use the same Automation
* parser and effective match set. Malformed standalone calls additionally keep
* the retired matcher's fail-soft contract by skipping invalid list items.
*/
export function matchesCronField(field: string, value: number, min: number, max: number): boolean {
if (field === '*') return true;
const parsed = parseCronField(
field,
{ name: 'minute', min, max },
PROFILE_POLICIES['automation-v1'],
{ matcherOnly: true, candidates: [value] },
);
return parsed.ok && parsed.value.values.has(value);
}
function parseCronField(
input: string,
spec: CronFieldSpec,
policy: CronCompatibilityPolicy,
options?: ParseCronFieldOptions,
): CronParseResult<ParsedCronField> {
if (!policy.allowAliases && !/^[\d*,/\-]+$/.test(input)) {
return parseError('unsupported_syntax', spec.name);
}
const matcherOnly = options?.matcherOnly === true;
const normalizedInput =
policy.allowAliases && spec.aliases ? translateCronAliases(input, spec.aliases) : input;
const rawParts = normalizedInput.split(',');
const values = new Set<number>();
const validationValues = new Set<number>();
let hasWildcardBase = false;
for (const rawPart of rawParts) {
if (rawPart.length === 0) {
if (matcherOnly) continue;
return parseError('empty_list_item', spec.name);
}
const stepParts = rawPart.split('/');
if (stepParts.length > 2 && !policy.legacyTokenCoercion) {
return parseError('invalid_step', spec.name);
}
const base = stepParts[0] ?? '';
const hasStep = stepParts[1] !== undefined;
let step = 1;
if (hasStep) {
if (matcherOnly) {
step = Number.parseInt(stepParts[1] ?? '', 10);
if (Number.isNaN(step) || step <= 0) continue;
} else {
const stepMax = policy.limitStepToFieldWidth
? spec.max - spec.min + 1
: Number.POSITIVE_INFINITY;
const parsedStep = parseCronInteger(stepParts[1] ?? '', 1, stepMax, spec, policy);
if (!parsedStep.ok) return parsedStep;
step = parsedStep.value;
}
}
const bareStar = base === '*' && !hasStep;
if (matcherOnly && bareStar) continue;
const ignoreEffectivePart = bareStar && rawParts.length > 1 && policy.ignoreBareStarInList;
let start = 0;
let end = -1;
let validationStart = 0;
let validationEnd = -1;
let contributesEffectiveMatches = true;
if (base === '*') {
hasWildcardBase = true;
start = spec.min;
end = spec.max;
validationStart = spec.min;
validationEnd = spec.max;
} else if (base.includes('-')) {
const range = base.split('-');
if (matcherOnly) {
start = Number(range[0] ?? '');
end = Number(range[1] ?? '');
if (Number.isNaN(start) || Number.isNaN(end)) continue;
} else if (range.length !== 2 && !policy.legacyTokenCoercion) {
return parseError('invalid_range', spec.name);
} else {
const parsedStart = parseCronInteger(range[0] ?? '', spec.min, spec.max, spec, policy);
if (!parsedStart.ok) return parsedStart;
const parsedEnd = parseCronInteger(range[1] ?? '', spec.min, spec.max, spec, policy);
if (!parsedEnd.ok) return parsedEnd;
if (parsedStart.value > parsedEnd.value) {
return parseError('reversed_range', spec.name);
}
validationStart = parsedStart.value;
validationEnd = parsedEnd.value;
if (policy.legacyTokenCoercion) {
// The old Automation validator used parseInt for ranges while its
// matcher used Number. Validate once above, then compile the matcher's
// effective values so callers never interpret the source again.
start = Number(range[0] ?? '');
end = Number(range[1] ?? '');
contributesEffectiveMatches = !Number.isNaN(start) && !Number.isNaN(end);
} else {
start = parsedStart.value;
end = parsedEnd.value;
}
}
} else {
if (matcherOnly) {
start = Number.parseInt(base, 10);
if (Number.isNaN(start)) continue;
} else {
const parsed = parseCronInteger(base, spec.min, spec.max, spec, policy);
if (!parsed.ok) return parsed;
start = parsed.value;
validationStart = parsed.value;
}
end = hasStep && (matcherOnly || policy.singleValueStepExtendsToMax) ? spec.max : start;
validationEnd = end;
}
if (!matcherOnly) {
for (let candidate = validationStart; candidate <= validationEnd; candidate += step) {
validationValues.add(spec.normalizeSunday === true && candidate === 7 ? 0 : candidate);
}
}
if (ignoreEffectivePart || !contributesEffectiveMatches) continue;
// Enumerating the field's tiny integer domain also reproduces legacy range
// coercions such as `1.5-5.5` without leaking raw-token logic into matching.
const addCandidate = (candidate: number): void => {
if (!(candidate >= start && candidate <= end)) return;
if (hasStep && (candidate - start) % step !== 0) return;
values.add(spec.normalizeSunday === true && candidate === 7 ? 0 : candidate);
};
if (options?.candidates) {
for (const candidate of options.candidates) addCandidate(candidate);
} else {
for (let candidate = spec.min; candidate <= spec.max; candidate += 1) {
addCandidate(candidate);
}
}
}
if (values.size === 0 && !matcherOnly && !policy.allowEmptyMatchSet) {
return parseError('empty_field', spec.name);
}
const wildcard =
policy.wildcardMode === 'any-star-base' ? hasWildcardBase : normalizedInput === '*';
return {
ok: true,
value: {
wildcard,
values,
validationValues,
validationWildcard: normalizedInput === '*',
},
};
}
function parseCronInteger(
input: string,
min: number,
max: number,
spec: CronFieldSpec,
policy: CronCompatibilityPolicy,
): CronParseResult<number> {
const value = policy.legacyTokenCoercion ? Number.parseInt(input, 10) : Number(input);
const validSyntax = policy.legacyTokenCoercion || /^\d+$/.test(input);
const validInteger = policy.legacyTokenCoercion
? Number.isInteger(value)
: Number.isSafeInteger(value);
if (!validSyntax || !validInteger) {
return parseError('invalid_integer', spec.name, min, max);
}
if (value < min || value > max) {
return parseError('out_of_range', spec.name, min, max);
}
return { ok: true, value };
}
function translateCronAliases(input: string, aliases: Readonly<Record<string, number>>): string {
return input.replace(/[a-zA-Z]+/g, (token) => {
const alias = aliases[token.toLowerCase()];
return alias === undefined ? token : String(alias);
});
}
function hasImpossibleCalendarDate(expression: ParsedCronExpression): boolean {
if (
expression.dayOfMonth.validationWildcard ||
expression.month.validationWildcard ||
!expression.dayOfWeek.validationWildcard
) {
return false;
}
const maxDays = Math.max(
...[...expression.month.validationValues].map((month) => MAX_DAYS_IN_MONTH[month - 1] ?? 0),
);
return Math.min(...expression.dayOfMonth.validationValues) > maxDays;
}
function nextCronOccurrence(
expression: ParsedCronExpression,
after: number,
bounds: CronSearchBounds | undefined,
nextMinuteRounding: CronCompatibilityPolicy['nextMinuteRounding'],
): number | null {
if (!Number.isFinite(after)) return null;
if (bounds?.notBefore !== undefined && !Number.isFinite(bounds.notBefore)) return null;
if (bounds?.notAfter !== undefined && !Number.isFinite(bounds.notAfter)) return null;
if (!hasPotentialEffectiveMatch(expression)) return null;
// Advance in epoch minutes. Mutating local Date fields during a DST fold can
// re-encode the repeated wall-clock time with the earlier offset and produce
// a candidate at or before `after`, causing a scheduler re-fire loop. The
// truncation branch retains Automation's pre-epoch behavior; both profiles
// are identical for every normal positive timestamp.
const firstMinuteAfter =
nextMinuteRounding === 'truncate'
? after - (after % MINUTE_MS) + MINUTE_MS
: (Math.floor(after / MINUTE_MS) + 1) * MINUTE_MS;
const notBefore = bounds?.notBefore;
const boundedStart =
notBefore === undefined
? firstMinuteAfter
: Math.max(firstMinuteAfter, Math.ceil(notBefore / MINUTE_MS) * MINUTE_MS);
const defaultEnd = firstMinuteAfter + (MAX_SEARCH_MINUTES - 1) * MINUTE_MS;
const searchEnd = Math.min(defaultEnd, bounds?.notAfter ?? defaultEnd);
if (
!Number.isFinite(boundedStart) ||
!Number.isFinite(searchEnd) ||
boundedStart > searchEnd ||
Number.isNaN(new Date(boundedStart).getTime())
) {
return null;
}
for (let candidate = boundedStart; candidate <= searchEnd; candidate += MINUTE_MS) {
const date = new Date(candidate);
if (cronExpressionMatches(expression, date)) return candidate;
}
return null;
}
/**
* Detect effective empty sets before the bounded minute scan. DOM and DOW use
* Vixie OR only when both are restricted, so one restricted side may remain
* empty when the other can still match.
*/
function hasPotentialEffectiveMatch(expression: ParsedCronExpression): boolean {
if (
expression.minute.values.size === 0 ||
expression.hour.values.size === 0 ||
expression.month.values.size === 0
) {
return false;
}
const hasDayOfMonth = expression.dayOfMonth.values.size > 0;
const hasDayOfWeek = expression.dayOfWeek.values.size > 0;
if (!expression.dayOfMonth.wildcard && !expression.dayOfWeek.wildcard) {
return hasDayOfMonth || hasDayOfWeek;
}
return hasDayOfMonth && hasDayOfWeek;
}
function cronExpressionMatches(expression: ParsedCronExpression, date: Date): boolean {
if (!expression.minute.values.has(date.getMinutes())) return false;
if (!expression.hour.values.has(date.getHours())) return false;
if (!expression.month.values.has(date.getMonth() + 1)) return false;
const dayOfMonthMatches = expression.dayOfMonth.values.has(date.getDate());
const dayOfWeekMatches = expression.dayOfWeek.values.has(date.getDay());
// Vixie cron treats DOM and DOW as OR only when both fields are restricted.
// When either field is a wildcard it is a no-op, so matching stays AND.
if (!expression.dayOfMonth.wildcard && !expression.dayOfWeek.wildcard) {
return dayOfMonthMatches || dayOfWeekMatches;
}
return dayOfMonthMatches && dayOfWeekMatches;
}
function parseError<T = never>(
code: CronCompileErrorCode,
field?: CronFieldName,
min?: number,
max?: number,
): CronParseResult<T> {
return {
ok: false,
error: {
code,
...(field ? { field } : {}),
...(min !== undefined ? { min } : {}),
...(max !== undefined ? { max } : {}),
},
};
}