Spaces:
Paused
Paused
| 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, | |
| }); | |
| // Cron operates at second granularity, so floor nowMs to the start of the | |
| // current second. We ask croner for the next occurrence strictly *after* | |
| // nowSecondMs so that a job whose schedule matches the current second is | |
| // never re-scheduled into the same (already-elapsed) second. | |
| // | |
| // Previous code used `nowSecondMs - 1` which caused croner to return the | |
| // current second as a valid next-run, leading to rapid duplicate fires when | |
| // multiple jobs triggered simultaneously (see #14164). | |
| const nowSecondMs = Math.floor(nowMs / 1000) * 1000; | |
| const next = cron.nextRun(new Date(nowSecondMs)); | |
| if (!next) { | |
| return undefined; | |
| } | |
| const nextMs = next.getTime(); | |
| return Number.isFinite(nextMs) && nextMs > nowSecondMs ? nextMs : undefined; | |
| } | |