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

124 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.