Initial Commit
This commit is contained in:
123
openspec/specs/cron/spec.md
Normal file
123
openspec/specs/cron/spec.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user