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:
- Lets
@suaveplan/validation/@suaveplan/api-schemas/ future browser-tier UIs validate + display cron expressions without depending on the scheduler. - Keeps
@suaveplan/job-schedulerfocused on the runtime concern (queue, retry, concurrency, lifecycle). - Matches the existing genesis pattern of one-primitive-per-package (cf.
crypto-hash/crypto-curve/crypto-ciphersplit out ofcrypto).
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 tonextRun/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_INVALIDthrown whenIntl.DateTimeFormatrejects 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-schemasforcronExpression()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.