7.8 KiB
cron
Purpose
Universal-tier cron-expression parser and next-fire-time engine for the Suaveplan monorepo. Provides parseCron / isValidCron, a frozen CronExpression with nextRun / nextRuns / previousRun / matches, IANA-timezone- and DST-aware scheduling, and an optional thin CronScheduler driver. Croner-inspired surface, written natively against Intl.DateTimeFormat with zero external cron-parsing dependencies. Consumed by @suaveplan/job-scheduler for runtime scheduling, and by validation / UI tiers for expression checking and "next run" displays without pulling the full job-runner footprint.
Requirements
Requirement: Parse standard 5-field crontab syntax
The package SHALL parse standard 5-field crontab expressions of the form <minute> <hour> <day-of-month> <month> <day-of-week> returning a CronExpression whose fields exposes the expanded value sets for each position.
Each field SHALL accept: numeric literals, ranges (a-b), lists (a,b,c), step values (*/n and a-b/n), and the wildcard *. Month and day-of-week fields SHALL additionally accept case-insensitive alphabetic aliases (JAN–DEC, SUN–SAT). Day-of-week SHALL accept both 0 and 7 for Sunday.
Invalid syntax SHALL throw CRON.INVALID_EXPRESSION; out-of-range numeric values SHALL throw CRON.FIELD_OUT_OF_RANGE; unsupported extensions (e.g. L, W, # without explicit opt-in) SHALL throw CRON.UNSUPPORTED_SYNTAX.
Scenario: Step value over wildcard
- GIVEN the expression
*/15 * * * * - WHEN parsed
- THEN
fields.minutesequals[0, 15, 30, 45]andfields.hoursequals[0..23].
Scenario: Alphabetic alias normalization
- GIVEN the expression
0 9 * * mon-fri - WHEN parsed
- THEN
fields.daysOfWeekequals[1, 2, 3, 4, 5].
Requirement: Parse macro shortcuts
The package SHALL expand the macros @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly to their canonical 5-field expansions before field tokenization.
Scenario: @daily macro
- GIVEN the expression
@daily - WHEN parsed
- THEN
fields.minutes === [0] && fields.hours === [0]and every remaining field is the full wildcard range.
Requirement: Optional seconds and year fields
The package SHALL accept 6-field expressions (seconds minute hour dom mon dow) when ParseOptions.seconds === true, and 7-field Quartz-style expressions (seconds minute hour dom mon dow year) when both seconds and years are true. With both options unset, only 5-field expressions SHALL be accepted; any other field count SHALL throw CRON.INVALID_EXPRESSION.
Scenario: Six-field expression rejected by default
- GIVEN the expression
0 */5 * * * *parsed with default options - WHEN
parseCronis called - THEN it throws
CRON.INVALID_EXPRESSION.
Requirement: Compute next fire time with timezone awareness
CronExpression.nextRun(after?, tz?) SHALL return the smallest epoch-millisecond timestamp strictly greater than after (default: caller Clock.now()) whose wall-clock representation in tz (default: UTC) matches the parsed field set. IANA timezone names SHALL be resolved through Intl.DateTimeFormat with hourCycle: 'h23'; an invalid IANA name SHALL throw CRON.TIMEZONE_INVALID.
A bounded forward search horizon (8 years default) SHALL apply; expressions with no fire time inside the horizon (e.g. 0 0 31 2 *) SHALL throw CRON.NEXT_RUN_NOT_FOUND rather than loop indefinitely.
Scenario: Next run strictly after the supplied instant
- GIVEN the expression
0 * * * *andafter = 2026-05-11T10:30:00Z - WHEN
nextRun(after, 'UTC')is called - THEN it returns the epoch millis for
2026-05-11T11:00:00Z.
Scenario: Impossible expression exhausts horizon
- GIVEN the expression
0 0 31 2 * - WHEN
nextRun()is called - THEN it throws
CRON.NEXT_RUN_NOT_FOUND.
Requirement: DST transition semantics
When the local-wall-clock instant produced by the expression does not exist (DST spring-forward), nextRun SHALL return the next instant whose wall-clock representation matches the expression at or after the skipped period. When the local-wall-clock instant is ambiguous (DST fall-back), nextRun SHALL return the first of the two real instants and SHALL NOT fire a second time for the repeated wall-clock hour.
Scenario: Spring-forward skipped hour
- GIVEN the expression
0 2 * * *inAmerica/New_Yorkon the 2026 spring-forward date (March 8) - WHEN
nextRun(2026-03-08T06:00:00Z)is called - THEN the next fire is
2026-03-08T07:00:00Z(03:00 local), not 02:00 local (which does not exist).
Requirement: Reverse and batch helpers
CronExpression.previousRun(before?, tz?) SHALL return the largest epoch-millis strictly less than before matching the expression, subject to the same bounded horizon (8 years default backward), or throw CRON.NEXT_RUN_NOT_FOUND if exhausted. nextRuns(count, after?, tz?) SHALL return a strictly monotonically increasing array of count fire times. matches(when, tz?) SHALL return true iff the wall-clock representation of when in tz matches every field of the expression.
Scenario: nextRuns monotonicity
- GIVEN any parseable expression
e, any timestampt, andcount = 10 - WHEN
nextRuns(10, t)is called - THEN every adjacent pair
(r[i], r[i+1])satisfiesr[i] < r[i+1].
Requirement: Non-throwing validation helper
isValidCron(expr, options?) SHALL return true if parseCron(expr, options) would succeed and false otherwise; it SHALL NOT throw for any string input.
Scenario: Garbage input is false, not thrown
- GIVEN the input
"not-a-cron-expression" - WHEN
isValidCronis called - THEN it returns
falseand does not throw.
Requirement: Typed error registry
The package SHALL export CronErrorCode union and registerCronErrorCodes() registered via @suaveplan/error.defineCodes("CRON", [...]), covering exactly the codes: CRON.INVALID_EXPRESSION, CRON.FIELD_OUT_OF_RANGE, CRON.UNSUPPORTED_SYNTAX, CRON.TIMEZONE_INVALID, CRON.NEXT_RUN_NOT_FOUND. Registration SHALL be eager at module evaluation.
Requirement: Optional in-process scheduler
The package SHALL expose a CronScheduler class under the ./scheduler subpath that drives registered handlers from setTimeout based on per-job nextRun. The scheduler SHALL accept an injectable Clock. Handler errors SHALL be routed to options.onError when supplied and swallowed otherwise. start() and stop() SHALL be idempotent; stop() SHALL await in-flight handler completion before resolving.
The scheduler is intentionally minimal — queue/retry/concurrency/lifecycle responsibilities live in @suaveplan/job-scheduler which consumes this package for expression parsing.
Scenario: Stop awaits in-flight handler
- GIVEN a
CronSchedulerrunning a handler that takes 50ms to resolve - WHEN
stop()is called while the handler is mid-execution - THEN the returned promise resolves only after the handler resolves.
Requirement: Observability surface
The package SHALL emit OpenTelemetry instruments via @opentelemetry/api:
- Histogram
cron.parse.duration_mswith attributeoutcome ∈ {"success", "error"}. - Histogram
cron.next_run.duration_mswith attributesoutcome ∈ {"success", "error"}andtz(the resolved timezone label). - Counter
cron.parse_totalwith attributeoutcome. - Counter
cron.next_run_totalwith attributesoutcomeandtz. - Counter
cron.dst_transition_totalwith attributeskind ∈ {"spring-forward", "fall-back"}andtz, incremented whennextRunresolves through a DST transition.
Attribute cardinality SHALL be bounded — outcome and kind are closed enums; tz SHALL be the IANA name supplied by the caller without further enrichment.