Files
suaveplan/openspec/specs/cron/spec.md
2026-05-27 23:29:53 +01:00

7.8 KiB
Raw Blame History

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.minutes equals [0, 15, 30, 45] and fields.hours equals [0..23].

Scenario: Alphabetic alias normalization

  • GIVEN the expression 0 9 * * mon-fri
  • WHEN parsed
  • THEN fields.daysOfWeek equals [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 parseCron is 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 * * * * and after = 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 * * * in America/New_York on 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 timestamp t, and count = 10
  • WHEN nextRuns(10, t) is called
  • THEN every adjacent pair (r[i], r[i+1]) satisfies r[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 isValidCron is called
  • THEN it returns false and 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 CronScheduler running 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_ms with attribute outcome ∈ {"success", "error"}.
  • Histogram cron.next_run.duration_ms with attributes outcome ∈ {"success", "error"} and tz (the resolved timezone label).
  • Counter cron.parse_total with attribute outcome.
  • Counter cron.next_run_total with attributes outcome and tz.
  • Counter cron.dst_transition_total with attributes kind ∈ {"spring-forward", "fall-back"} and tz, incremented when nextRun resolves 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.