Initial Commit
This commit is contained in:
197
openspec/archive/2026-05-12-primitives-cron/proposal.md
Normal file
197
openspec/archive/2026-05-12-primitives-cron/proposal.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# 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<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
|
||||
|
||||
```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`.
|
||||
72
openspec/archive/2026-05-12-primitives-cron/tasks.md
Normal file
72
openspec/archive/2026-05-12-primitives-cron/tasks.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Tasks: primitives-cron
|
||||
|
||||
**Change ID**: primitives-cron
|
||||
**Package**: `@suaveplan/cron`
|
||||
**Target path**: `packages/primitives/cron`
|
||||
**Prerequisites on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] Create directory tree per `proposal.md`.
|
||||
- [x] `package.json` at `0.1.0`, `"sideEffects": false`, exports root + `./parse` + `./scheduler` + `./testing` subpaths.
|
||||
- [x] Deps: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`.
|
||||
- [x] DevDeps: `vitest catalog:testing`, `@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`, `@suaveplan/testing workspace:^`.
|
||||
- [x] `tsconfig.json` extending `library.json` (universal-tier — NO `library-node.json`).
|
||||
- [x] `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`.
|
||||
- [x] Scripts: `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check`.
|
||||
|
||||
## 2. Types + errors
|
||||
|
||||
- [x] `src/types/cron.types.ts`: `CronFields`, `CronExpression`, `ParseOptions`, `CronSchedulerOptions`. Zod schemas at the boundary.
|
||||
- [x] `src/types/cron.types.md` (≥200 words, both examples, full API reference).
|
||||
- [x] `src/error-codes.ts`: 5 codes per proposal via `defineCodes("CRON", [...])`; eager `registerCronErrorCodes()` call.
|
||||
|
||||
## 3. Field + macro parsing
|
||||
|
||||
- [x] `src/parse/cron-fields/cron-fields.ts`: tokenize one field (range / list / step / `*` / numeric alias JAN-DEC + SUN-SAT). Validate per-field range. Throw `CRON.FIELD_OUT_OF_RANGE` / `CRON.UNSUPPORTED_SYNTAX`.
|
||||
- [x] `src/parse/macros/macros.ts`: expand `@yearly`/`@annually`/`@monthly`/`@weekly`/`@daily`/`@midnight`/`@hourly` → canonical 5-field expression.
|
||||
- [x] `src/parse/cron-expression/cron-expression.ts`: orchestrator. 5/6/7-field detection per `ParseOptions`. `parseCron(expr, options)` returns frozen `CronExpression` with bound `nextRun` / `nextRuns` / `previousRun` / `matches`. `isValidCron` non-throwing variant.
|
||||
- [x] Tests for every parser file. Co-located `.md` for every impl file.
|
||||
|
||||
## 4. Next-run + timezone
|
||||
|
||||
- [x] `src/next-run/timezone/timezone.ts`: `wallClockComponents(epochMs, tz)` + inverse `epochMsForWallClock(components, tz)` via `Intl.DateTimeFormat` with `timeZone` + `hourCycle: 'h23'`. DST spring-forward = next valid minute. DST fall-back = first occurrence. `CRON.TIMEZONE_INVALID` on bad IANA name.
|
||||
- [x] `src/next-run/next-run/next-run.ts`: bit-set field-match algorithm (croner-style). Bounded forward search horizon (default: 8 years per Quartz convention) → `CRON.NEXT_RUN_NOT_FOUND` on exhaustion (e.g. `0 0 31 2 *`). `previousRun` mirror.
|
||||
- [x] Tests including known-impossible expressions, leap-day, DST crossings (US `America/New_York`, EU `Europe/London`, SH `Australia/Sydney`).
|
||||
|
||||
## 5. Optional in-process scheduler
|
||||
|
||||
- [x] `src/scheduler/cron-scheduler/cron-scheduler.ts`: thin `setTimeout`-based driver (NOT a full job runner). Injectable `Clock`. Handler errors routed to `options.onError` (default: swallow). `start` / `stop` idempotent. `stop()` awaits in-flight handlers.
|
||||
- [x] Test with `FakeClock` from `@suaveplan/testing`.
|
||||
|
||||
## 6. OTel surface
|
||||
|
||||
- [x] `src/otel/cron-otel.ts`:
|
||||
- Histograms: `cron.parse.duration_ms{outcome}`, `cron.next_run.duration_ms{outcome,tz}`.
|
||||
- Counters: `cron.parse_total{outcome}`, `cron.next_run_total{outcome,tz}`, `cron.dst_transition_total{kind: 'spring-forward'|'fall-back', tz}`.
|
||||
- [x] Co-located `.md`.
|
||||
|
||||
## 7. Integration + property + vectors
|
||||
|
||||
- [x] `tests/vectors.test.ts`: ≥30 known (expression, after, tz, expected-nextRun) tuples. Cross-check at least 10 against croner's documented behaviour for confidence (research-only — no runtime dep).
|
||||
- [x] `tests/property.test.ts` (fast-check):
|
||||
- `nextRun(parseCron(e), t) > t` for all parseable `e` and finite `t`.
|
||||
- `nextRuns(parseCron(e), n, t)` is strictly monotonic increasing.
|
||||
- `matches(parseCron(e), nextRun(parseCron(e), t))` is true.
|
||||
- `previousRun(parseCron(e), nextRun(parseCron(e), t)) <= t` when both finite.
|
||||
- [x] `tests/integration.test.ts`: `CronScheduler` over 3 jobs with different intervals under `FakeClock`; verify fire order + counts + stop drain; `{ timeout: 30000 }`.
|
||||
|
||||
## 8. Docs + verify + publish
|
||||
|
||||
- [x] Root `README.md`: parse quick-start, `nextRun(tz)` example, `CronScheduler` example, FakeClock test recipe, full API table, comparison-with-croner note (why we wrote our own).
|
||||
- [x] `bunx turbo lint --filter=@suaveplan/cron` — zero errors.
|
||||
- [x] `bunx turbo typecheck --filter=@suaveplan/cron` — zero errors.
|
||||
- [x] `bunx turbo test --filter=@suaveplan/cron` — all green.
|
||||
- [x] `bunx turbo test:coverage --filter=@suaveplan/cron` — 100%.
|
||||
- [x] `bunx turbo docs:check --filter=@suaveplan/cron` — 100%.
|
||||
- [ ] `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
|
||||
- [ ] Update `CLAUDE.md` package listing under "Time / sequencing / validation".
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/cron`.
|
||||
- [ ] Archive to `openspec/archive/2026-05-11-primitives-cron/`.
|
||||
Reference in New Issue
Block a user