import { InvalidInstantError, InvalidTimezoneError } from "./errors.js"; /** * Wall-clock reading of an instant in some zone. Fields are the raw numeric * calendar values (month 1-12, day 1-31, hour 0-23). */ export interface WallClock { readonly year: number; readonly month: number; readonly day: number; readonly hour: number; readonly minute: number; readonly second: number; } /** * Nominal local date-time, e.g. `2024-03-10T02:30`. Lexicographic order of * these strings is chronological order, which is what the * `last_fired_nominal_local` refire gate relies on. */ export type NominalLocal = string; const WALL_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }; const wallFormatters = new Map(); function wallFormatter(zone: string): Intl.DateTimeFormat { const cached = wallFormatters.get(zone); if (cached) return cached; const formatter = new Intl.DateTimeFormat("en-US", { ...WALL_FORMAT_OPTIONS, timeZone: zone, }); wallFormatters.set(zone, formatter); return formatter; } const IDIOMS_REJECTED = new Set(["local", "system", "server", "server-local", "machine"]); /** * Validate a timezone and return its canonical IANA id. * * Accepts real IANA ids (including `Etc/GMT+5` style fixed zones and `UTC`), * canonicalizing whatever Intl resolves (e.g. `US/Eastern` -> `America/New_York`). * Rejects unknown ids, offset spellings like `UTC+3`/`+05:30`, empty input and * the strings `local`/`system` — the schedule zone is never allowed to fall * back to the process zone. */ export function canonicalTimezone(timezone: string): string { if (typeof timezone !== "string") { throw new InvalidTimezoneError(`timezone must be a string, got ${typeof timezone}`); } const trimmed = timezone.trim(); if (trimmed === "") { throw new InvalidTimezoneError("timezone must be an explicit IANA zone id, not an empty string"); } if (IDIOMS_REJECTED.has(trimmed.toLowerCase())) { throw new InvalidTimezoneError( `timezone "${timezone}" is not an IANA zone id; a schedule must carry an explicit zone (never server-local)`, ); } if (/^[+-]?\d{1,2}(:?\d{2})?$/.test(trimmed) || /^UTC[+-]\d/.test(trimmed)) { throw new InvalidTimezoneError( `timezone "${timezone}" is a UTC offset, not an IANA zone id; store an IANA zone (UTC is allowed)`, ); } let resolved: string; try { resolved = new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).resolvedOptions().timeZone; } catch { throw new InvalidTimezoneError(`unknown IANA time zone: "${timezone}"`); } if (!resolved) { throw new InvalidTimezoneError(`unknown IANA time zone: "${timezone}"`); } return resolved; } /** True when `canonicalTimezone` would accept the value. */ export function isValidTimezone(timezone: string): boolean { try { canonicalTimezone(timezone); return true; } catch { return false; } } /** Validate and canonicalize, throwing `InvalidTimezoneError` on rejection. */ export function assertValidTimezone(timezone: string): string { return canonicalTimezone(timezone); } /** Local wall clock struck by an instant, in the given zone. */ export function wallOfInstant(epochMs: number, zone: string): WallClock { if (zone === "UTC") { return decodeUtc(epochMs); } const parts = wallFormatter(zone).formatToParts(new Date(epochMs)); let year = 0; let month = 0; let day = 0; let hour = 0; let minute = 0; let second = 0; for (const part of parts) { switch (part.type) { case "year": year = Number(part.value); break; case "month": month = Number(part.value); break; case "day": day = Number(part.value); break; case "hour": hour = Number(part.value); break; case "minute": minute = Number(part.value); break; case "second": second = Number(part.value); break; default: break; } } return { year, month, day, hour, minute, second }; } /** The UTC instant a wall-clock reading would denote if the offset were zero. */ export function epochOfWall(wall: WallClock): number { return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second); } function decodeUtc(epochMs: number): WallClock { const d = new Date(epochMs); return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(), hour: d.getUTCHours(), minute: d.getUTCMinutes(), second: d.getUTCSeconds(), }; } /** * Zone offset (ms east of UTC) in effect at an instant. Minute-precision * arithmetic against `Intl`; UTC short-circuits without touching a formatter. */ export function offsetMs(epochMs: number, zone: string): number { if (zone === "UTC") return 0; return epochOfWall(wallOfInstant(epochMs, zone)) - epochMs; } const OFFSET_ISO_TAIL = /(?:Z|[+-]\d{2}:\d{2})$/; /** * Coerce an instant input to epoch milliseconds. * * `Date` and finite numbers pass through. Strings must be ISO-8601; a string * that carries a time component must also carry an explicit UTC offset (`Z` or * `±hh:mm`) — otherwise `Date.parse` would silently apply the server's local * zone, which this library exists to prevent. */ export function toInstantMs(value: Date | number | string): number { if (value instanceof Date) { const ms = value.getTime(); if (Number.isNaN(ms)) throw new InvalidInstantError("invalid Date (NaN)"); return ms; } if (typeof value === "number") { if (!Number.isFinite(value)) throw new InvalidInstantError(`invalid instant number: ${value}`); return value; } if (typeof value === "string") { const trimmed = value.trim(); const hasTime = /[T ]\d/.test(trimmed); if (hasTime && !OFFSET_ISO_TAIL.test(trimmed)) { throw new InvalidInstantError( `instant "${value}" has a time but no UTC offset; supply ...Z or ...±hh:mm (never server-local)`, ); } const ms = Date.parse(trimmed); if (Number.isNaN(ms)) throw new InvalidInstantError(`unparseable instant: "${value}"`); return ms; } throw new InvalidInstantError(`unsupported instant input: ${String(value)}`); } type ProcessVersionsLike = { tz?: string }; /** * tzdata version backing this process (`process.versions.tz`, e.g. `2026a`), * or `"unknown"` outside Node. Recorded per schedule so tzdata-driven * re-materialization can detect drift. */ export function currentTzdataVersion(): string { const proc = (globalThis as { process?: { versions?: ProcessVersionsLike } }).process; return proc?.versions?.tz ?? "unknown"; }