# OpenSpec Change: primitives-cron - **Change ID**: primitives-cron - **Package**: `@suaveplan/cron` - **Category**: primitives (universal-tier) - **Target path**: `packages/primitives/cron` - **Status**: PROPOSED - **Date**: 2026-05-11 - **Reference**: croner (https://github.com/hexagon/croner) — design inspiration, not a dep - **Related**: amends `enterprise-server-job-scheduler` (cron-parser removed from that package; replaced by a dep on this one) --- ## Summary Deliver `@suaveplan/cron` as a standalone universal-tier primitive: cron-expression parsing, next-fire calculation, timezone + DST handling, optional in-process scheduling. Split out of `enterprise-server-job-scheduler` so that cron-expression parsing is reusable outside the job runner — e.g. config-schema validation (`isValidCron`), UI "next run" badges, declarative schedule rendering in admin tooling — without pulling the scheduler's queue/retry/concurrency/event-bus machinery. Modeled on croner's surface (5/6/7-field cron + macros + `nextRun(tz)`), but written natively (no external cron-parsing dep) following the genesis primitive conventions (Zod boundaries, `defineCodes` registry, OTel surface, injectable `Clock`). --- ## Why split The bundled design in `enterprise-server-job-scheduler` couples cron parsing to a server-tier package with deps on `@suaveplan/retry`, `@suaveplan/events`, `@suaveplan/logger`. Consumers that only need expression parsing (browser tier, validation tier, config tier) cannot use it without that footprint. Splitting `cron` as a leaf primitive: 1. Lets `@suaveplan/validation` / `@suaveplan/api-schemas` / future browser-tier UIs validate + display cron expressions without depending on the scheduler. 2. Keeps `@suaveplan/job-scheduler` focused on the runtime concern (queue, retry, concurrency, lifecycle). 3. Matches the existing genesis pattern of one-primitive-per-package (cf. `crypto-hash` / `crypto-curve` / `crypto-cipher` split out of `crypto`). --- ## Current-state audit (genesis) Does not exist on genesis. `packages/primitives/timing` covers async scheduling (stopwatch, throttle/debounce, interval timer, countdown, vector clock) but NOT cron expressions. `packages/primitives/datetime` covers RRule-subset recurrence (iCalendar-style daily/weekly/monthly/yearly) but NOT crontab syntax. No overlap. --- ## Available workspace deps on genesis | Package | Role | |---|---| | `@suaveplan/error` (`workspace:^`) | Typed `CRON.*` error codes | | `@suaveplan/types` (`workspace:^`) | `Clock` injection | | `zod` (`catalog:forms`) | Options validation | | `@opentelemetry/api` (`catalog:observability`) | Parse + nextRun histograms, outcome counters | | `tslib` (`catalog:build`) | Runtime helpers | Zero deps on other `@suaveplan/*` primitives. Universal-tier (no Node built-ins, no browser globals). --- ## Public API surface ```ts // Parsing function parseCron(expr: string, options?: ParseOptions): CronExpression function isValidCron(expr: string, options?: ParseOptions): boolean // Next-fire calculation interface CronExpression { readonly expr: string readonly fields: CronFields nextRun(after?: number | Date, tz?: string): number | null nextRuns(count: number, after?: number | Date, tz?: string): number[] previousRun(before?: number | Date, tz?: string): number | null matches(when: number | Date, tz?: string): boolean } interface CronFields { seconds?: number[] // 0-59 (only when 6/7-field) minutes: number[] // 0-59 hours: number[] // 0-23 daysOfMonth: number[] // 1-31 months: number[] // 1-12 daysOfWeek: number[] // 0-6 (Sun=0; both 0 and 7 accepted on parse) years?: number[] // when 7-field (Quartz-style) } interface ParseOptions { /** Accept 6-field (seconds + 5 std) or 7-field (Quartz). Default: 5 only. */ seconds?: boolean /** Accept Quartz year field. Default: false. */ years?: boolean /** Reject day-of-month + day-of-week both specified (Vixie semantics). Default: false (OR them). */ strictDoW?: boolean } // Macros: @yearly @annually @monthly @weekly @daily @midnight @hourly // Step values: * /5, 10-20/2 // Ranges: 1-5 // Lists: 1,3,5 // Aliases: JAN-DEC, SUN-SAT (case-insensitive) // Optional in-process scheduler (thin wrapper; full job runner lives in @suaveplan/job-scheduler) class CronScheduler { constructor(options?: CronSchedulerOptions) add(expr: string, handler: () => void | Promise, options?: { tz?: string; id?: string }): string remove(id: string): boolean list(): Array<{ id: string; expr: string; nextRun: number | null }> start(): void stop(): Promise } interface CronSchedulerOptions { clock?: Clock /** Errors thrown by handlers are swallowed by default; pass a sink to observe. */ onError?: (err: unknown, id: string) => void } type CronErrorCode = | 'CRON.INVALID_EXPRESSION' | 'CRON.FIELD_OUT_OF_RANGE' | 'CRON.UNSUPPORTED_SYNTAX' | 'CRON.TIMEZONE_INVALID' | 'CRON.NEXT_RUN_NOT_FOUND' // search horizon exhausted function registerCronErrorCodes(): void ``` --- ## Timezone + DST semantics - Timezone defaults to UTC. Pass IANA name (e.g. `"America/New_York"`) per-call to `nextRun` / `nextRuns` / `previousRun` / `matches`. - Use the platform `Intl.DateTimeFormat` (`timeZone`, `hourCycle: 'h23'`) to compute wall-clock components — no external timezone DB dep. - DST spring-forward: schedules during the skipped hour fire at the next existing wall-clock minute (e.g. `0 2 * * *` on a US spring-forward day fires at 03:00 local). - DST fall-back: schedules during the doubled hour fire **once** (first occurrence wins). - `CRON.TIMEZONE_INVALID` thrown when `Intl.DateTimeFormat` rejects the IANA name. --- ## File layout ```text packages/primitives/cron/ package.json # 0.0.0; "sideEffects": false; subpaths "./parse", "./scheduler" tsconfig.json # extends library.json (universal) vitest.config.ts # createTestConfig() README.md src/ index.ts # explicit named re-exports (rule 10) error-codes.ts # defineCodes("CRON", [...]) constants/index.ts types/ cron.types.ts cron.types.md parse/ cron-expression/ cron-expression.ts cron-expression.md cron-expression.test.ts index.ts cron-fields/ cron-fields.ts cron-fields.md cron-fields.test.ts index.ts macros/ macros.ts macros.md macros.test.ts index.ts next-run/ next-run/ next-run.ts next-run.md next-run.test.ts index.ts timezone/ timezone.ts timezone.md timezone.test.ts index.ts scheduler/ cron-scheduler/ cron-scheduler.ts cron-scheduler.md cron-scheduler.test.ts index.ts otel/ cron-otel.ts cron-otel.md tests/ integration.test.ts property.test.ts vectors.test.ts # known cron expression → expected fire times ``` --- ## Impact - **Downstream now**: `@suaveplan/job-scheduler` (amended proposal) depends on this for cron parsing + next-run; the scheduler keeps queue/retry/concurrency/lifecycle. - **Downstream future**: `@suaveplan/validation` / `@suaveplan/api-schemas` for `cronExpression()` schema; browser-tier admin UIs for "next run" displays. - **No genesis package currently depends on it** — greenfield primitive. **Prerequisites on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`.