Files
2026-05-27 23:29:53 +01:00

7.4 KiB

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

// 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<void>, options?: { tz?: string; id?: string }): string
  remove(id: string): boolean
  list(): Array<{ id: string; expr: string; nextRun: number | null }>
  start(): void
  stop(): Promise<void>
}

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

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.