import type { CronSchedule } from "../protocol/types.js";
const MINUTE_MS = 60_000;
const MAX_SEARCH_MINUTES = 366 * 24 * 60;
export function computeNextRunAt(schedule: CronSchedule, after: Date): Date | undefined {
if (schedule.type === "once") {
const runAt = new Date(schedule.runAt);
return Number.isNaN(runAt.getTime()) ? undefined : runAt;
}
return computeNextCronRunAt(schedule.expression, after);
}
export function computeNextCronRunAt(expression: string, after: Date): Date | undefined {
const parsed = parseCronExpression(expression);
if (!parsed) return undefined;
let candidate = new Date(Math.floor(after.getTime() / MINUTE_MS) * MINUTE_MS + MINUTE_MS);
for (let index = 0; index < MAX_SEARCH_MINUTES; index += 1) {
if (matchesCron(candidate, parsed)) {
return candidate;
}
candidate = new Date(candidate.getTime() + MINUTE_MS);
}
return undefined;
}
type ParsedCron = {
minutes: Set<number>;
hours: Set<number>;
daysOfMonth: Set<number>;
months: Set<number>;
daysOfWeek: Set<number>;
};
function parseCronExpression(expression: string): ParsedCron | undefined {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) {
return undefined;
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
const parsed = {
minutes: parseField(minute, 0, 59),
hours: parseField(hour, 0, 23),
daysOfMonth: parseField(dayOfMonth, 1, 31),
months: parseField(month, 1, 12),
daysOfWeek: parseField(dayOfWeek, 0, 7),
};
if (
!parsed.minutes ||
!parsed.hours ||
!parsed.daysOfMonth ||
!parsed.months ||
!parsed.daysOfWeek
) {
return undefined;
}
if (parsed.daysOfWeek.has(7)) {
parsed.daysOfWeek.add(0);
parsed.daysOfWeek.delete(7);
}
return parsed as ParsedCron;
}
function parseField(field: string, min: number, max: number): Set<number> | undefined {
const output = new Set<number>();
for (const part of field.split(",")) {
const trimmed = part.trim();
if (!trimmed) return undefined;
const stepParts = trimmed.split("/");
if (stepParts.length > 2) return undefined;
const base = stepParts[0];
const step = stepParts[1] === undefined ? 1 : Number.parseInt(stepParts[1], 10);
if (!Number.isInteger(step) || step <= 0) return undefined;
let start: number;
let end: number;
if (base === "*") {
start = min;
end = max;
} else if (base.includes("-")) {
const [rawStart, rawEnd] = base.split("-");
start = Number.parseInt(rawStart, 10);
end = Number.parseInt(rawEnd, 10);
} else {
start = Number.parseInt(base, 10);
end = start;
}
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
return undefined;
}
for (let value = start; value <= end; value += step) {
output.add(value);
}
}
return output;
}
function matchesCron(date: Date, cron: ParsedCron): boolean {
return (
cron.minutes.has(date.getUTCMinutes()) &&
cron.hours.has(date.getUTCHours()) &&
cron.daysOfMonth.has(date.getUTCDate()) &&
cron.months.has(date.getUTCMonth() + 1) &&
cron.daysOfWeek.has(date.getUTCDay())
);
}