import { Cron } from "croner"; import type { CronSchedule } from "./types.js"; import { parseAbsoluteTimeMs } from "./parse.js"; function resolveCronTimezone(tz?: string) { const trimmed = typeof tz === "string" ? tz.trim() : ""; if (trimmed) { return trimmed; } return Intl.DateTimeFormat().resolvedOptions().timeZone; } export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined { if (schedule.kind === "at") { // Handle both canonical `at` (string) and legacy `atMs` (number) fields. // The store migration should convert atMs→at, but be defensive in case // the migration hasn't run yet or was bypassed. const sched = schedule as { at?: string; atMs?: number | string }; const atMs = typeof sched.atMs === "number" && Number.isFinite(sched.atMs) && sched.atMs > 0 ? sched.atMs : typeof sched.atMs === "string" ? parseAbsoluteTimeMs(sched.atMs) : typeof sched.at === "string" ? parseAbsoluteTimeMs(sched.at) : null; if (atMs === null) { return undefined; } return atMs > nowMs ? atMs : undefined; } if (schedule.kind === "every") { const everyMs = Math.max(1, Math.floor(schedule.everyMs)); const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs)); if (nowMs < anchor) { return anchor; } const elapsed = nowMs - anchor; const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs)); return anchor + steps * everyMs; } const expr = schedule.expr.trim(); if (!expr) { return undefined; } const cron = new Cron(expr, { timezone: resolveCronTimezone(schedule.tz), catch: false, }); // Ask croner for the next occurrence starting from the NEXT second. // This prevents re-scheduling into the current second when a job fires // at 13:00:00.014 and completes at 13:00:00.021 — without this fix, // croner could return 13:00:00.000 (same second) causing a spin loop // where the job fires hundreds of times per second (see #17821). // // By asking from the next second (e.g., 13:00:01.000), we ensure croner // returns the following day's occurrence (e.g., 13:00:00.000 tomorrow). // // This also correctly handles the "before match" case: if nowMs is // 11:59:59.500, we ask from 12:00:00.000, and croner returns 12:00:00.000 // (today's match) since it uses >= semantics for the start time. const askFromNextSecondMs = Math.floor(nowMs / 1000) * 1000 + 1000; const next = cron.nextRun(new Date(askFromNextSecondMs)); if (!next) { return undefined; } const nextMs = next.getTime(); return Number.isFinite(nextMs) ? nextMs : undefined; }