first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
# @suaveplan/cron - Universal cron expression parser, next-fire calculator with timezone + DST support, and lightweight in-process scheduler
## Summary
Universal cron expression parser, next-fire calculator with
timezone + DST awareness, and an opt-in lightweight in-process
scheduler. Zero external cron dependencies — written natively against
`@suaveplan/error`, `@suaveplan/types`, and `Intl.DateTimeFormat`.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ - [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ - [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ - [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ - [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_
## Quick start
```typescript
import { parseCron, isValidCron } from "@suaveplan/cron";
const expr = parseCron("*/15 * * * *");
console.log(expr.fields.minutes); // [0, 15, 30, 45]
const next = expr.nextRun(Date.UTC(2026, 4, 11, 10, 7, 0), "America/New_York");
console.log(new Date(next as number).toISOString());
console.log(isValidCron("0 9 * * MON-FRI")); // true
console.log(isValidCron("garbage")); // false
```
## Scheduler example
```typescript
import { CronScheduler } from "@suaveplan/cron";
const scheduler = new CronScheduler({
onError: (err, id) => console.error(`job ${id} failed`, err),
});
scheduler.add("0 9 * * MON-FRI", async () => sendDailyDigest(), {
tz: "America/New_York",
});
scheduler.add("@hourly", () => collectMetrics());
scheduler.start();
process.on("SIGTERM", async () => {
await scheduler.stop();
process.exit(0);
});
```
## FakeClock test recipe
```typescript
import { CronScheduler } from "@suaveplan/cron";
import type { SchedulerTimers } from "@suaveplan/cron";
const now = { value: Date.UTC(2026, 4, 11, 9, 59, 0) };
const pending = new Map<number, { cb: () => void; fireAt: number }>();
let nextId = 1;
const timers: SchedulerTimers = {
setTimer: (cb, ms) => {
const id = nextId++;
pending.set(id, { cb, fireAt: now.value + ms });
return id;
},
clearTimer: (id) => pending.delete(id),
};
const scheduler = new CronScheduler({ clock: () => now.value, timers });
scheduler.add("0 * * * *", () => console.log("fired"));
scheduler.start();
// Fast-forward 1 hour.
now.value += 60 * 60 * 1000;
for (const [id, { cb, fireAt }] of pending) {
if (fireAt <= now.value) { pending.delete(id); cb(); }
}
await scheduler.stop();
```
## API reference
| Surface | Purpose |
| -------------------------------- | ------------------------------------------------------------------ |
| `parseCron(expr, options?)` | Parse a 5/6/7-field expression into a frozen `CronExpression`. |
| `isValidCron(expr, options?)` | Non-throwing parse check. |
| `CronExpression.nextRun` | Next epoch instant strictly greater than `after`. |
| `CronExpression.nextRuns` | Strictly monotonic array of `count` fire times. |
| `CronExpression.previousRun` | Largest epoch instant strictly less than `before`. |
| `CronExpression.matches` | Constant-time wall-clock membership check. |
| `CronScheduler` | Lightweight in-process driver (no queue/retry/concurrency). |
| `createCronScheduler` | Factory mirror. |
| `parseField`, `rangeFor` | Single-field tokenizer + per-kind range. |
| `expandMacro`, `macroTable` | Macro shortcut expansion (`@daily`, `@hourly`, …). |
| `toWallClock`, `fromWallClock` | Bidirectional epoch ↔ wall-clock conversion in any IANA zone. |
| `assertTimezone` | Eager IANA-zone validation. |
| `detectDstTransition` | DST classifier between two adjacent instants. |
| `CronError`, `CRON_CODES` | Typed error class + frozen code registry. |
| `recordParse`, `recordNextRun` | OpenTelemetry instrumentation helpers. |
## Why not croner
Croner is excellent reference material but ships as an opaque tarball,
imposes its own API conventions, and is not universal in the
sense. `@suaveplan/cron` lives at the same primitive
tier as `@suaveplan/datetime` and `@suaveplan/timing`: zero external
runtime deps beyond the foundation, typed errors via
`defineCodes`, Zod boundary schemas, an OTel surface, and the standard
five-files-per-feature layout. Splitting cron parsing out of the
job-scheduler keeps the workflow-tier scheduler focused on queue /
retry / concurrency concerns while letting validation packages and
browser UIs consume parsing without dragging in the runtime.
## Coverage
100 % Istanbul coverage on every implementation file is gated by CI:
```bash
bunx turbo test:coverage --filter=@suaveplan/cron
```

View File

@@ -0,0 +1,80 @@
# CronError
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CronError` is the single typed error class emitted by every public entry
point in `@suaveplan/cron`. It extends `SuaveplanError` with the literal
`CronErrorCode` union so `instanceof CronError` plus `err.code` lets
consumers branch on a finite, registered set of failure modes without
parsing free-form `message` strings. The companion `CRON_CODES` registry is
built via `defineCodes("CRON", [...])` so each fully-qualified code
(`CRON.INVALID_EXPRESSION`, etc.) is generated once and frozen at module
load.
## Features
- Five typed codes covering every documented failure mode: invalid
expression, field-out-of-range numerics, unsupported syntax (`L`/`W`/`#`),
invalid IANA timezone names, and exhausted next-run search horizons.
- Eager registration via `registerCronErrorCodes()` runs at module load.
- Runtime guard in the public constructor rejects unknown codes — defends
against JSON-deserialised payloads injecting unregistered codes.
- Cause-chaining via `SuaveplanError` (`options.cause`) and structured
`context` for telemetry redaction.
## Usage: Basic Example
```typescript
import { CRON_CODES, CronError } from "@suaveplan/cron";
try {
throw new CronError(CRON_CODES.INVALID_EXPRESSION, "cron.invalid_expression", {
context: { expr: "* * *" },
});
} catch (err) {
if (err instanceof CronError && err.code === CRON_CODES.INVALID_EXPRESSION) {
console.warn("rejected", err.context);
}
}
```
## Usage: Advanced Example
```typescript
import { CRON_CODES, CronError } from "@suaveplan/cron";
function wrap(cause: Error): CronError {
return new CronError(CRON_CODES.NEXT_RUN_NOT_FOUND, "cron.next_run_not_found", {
cause,
context: { horizonYears: 8 },
});
}
const wrapped = wrap(new Error("horizon exhausted"));
console.log(wrapped.code, wrapped.cause);
```
## API Reference
- `CRON_CODES` — frozen record mapping reason → fully-qualified code.
- `CronErrorCode` — string-literal union of every registered code.
- `class CronError extends SuaveplanError<CronErrorCode>` — public
constructor `(code, messageKey, options?)`. Throws `TypeError` for
unregistered codes.
- `registerCronErrorCodes(): number` — eager-registration probe that
returns the number of registered codes. Safe to call any number of
times — never has side effects beyond reading the frozen registry.
## Implementation Notes
The registry is captured into a `Set` once at module evaluation so the
constructor guard is O(1). The guard runs before `super()` so a malformed
code surfaces as a `TypeError` (not a half-constructed `CronError`).
`registerCronErrorCodes` is invoked at module load and is also exported so
callers performing dependency-injection tree-shaking inspection have an
explicit hook. The error class is `final` in spirit — subclassing is not
part of the public contract; consumers should always discriminate on
`err.code`.

View File

@@ -0,0 +1,89 @@
# parseField
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parseField` is the single-field tokenizer at the heart of cron parsing.
Each of the five (or six/seven) whitespace-separated positions in a cron
expression is independently expanded into the sorted, unique numeric set
of values it matches. The function is total over its `FieldKind`
discriminator: it accepts the canonical crontab syntax surface (literals,
ranges, lists, steps, wildcards, and alphabetic aliases for month +
day-of-week) and rejects every Quartz extension (`L`/`W`/`#`/`?`) with
typed errors so the caller can branch on `err.code`.
## Features
- Wildcards (`*`), literals, comma-separated lists, hyphenated ranges,
step values (`*/n` and `a-b/n` and `a/n`), case-insensitive aliases
(`JAN`-`DEC` for month, `SUN`-`SAT` for day-of-week).
- Day-of-week normalization: both `0` and `7` are accepted for Sunday and
collapse to `0` after sorting and deduplication.
- Range bounds per field exposed via `rangeFor(kind)` for downstream
next-run computations.
- Typed errors via `CronError`: out-of-range numerics, unsupported tokens,
and malformed atoms each surface a distinct `CronErrorCode`.
## Usage: Basic Example
```typescript
import { parseField } from "@suaveplan/cron";
const minutes = parseField("*/15", "minute");
console.log(Array.from(minutes)); // [0, 15, 30, 45]
const weekdays = parseField("MON-FRI", "dayOfWeek");
console.log(Array.from(weekdays)); // [1, 2, 3, 4, 5]
```
## Usage: Advanced Example
```typescript
import { parseField, rangeFor, CronError, CRON_CODES } from "@suaveplan/cron";
function expandOrFallback(token: string, kind: Parameters<typeof parseField>[1]) {
try {
return parseField(token, kind);
} catch (err) {
if (err instanceof CronError && err.code === CRON_CODES.UNSUPPORTED_SYNTAX) {
const range = rangeFor(kind);
return Object.freeze(
Array.from({ length: range.max - range.min + 1 }, (_, i) => range.min + i)
);
}
throw err;
}
}
console.log(expandOrFallback("L", "dayOfMonth")); // falls back to full range
```
## API Reference
- `parseField(token: string, kind: FieldKind): readonly number[]` — returns a
frozen, sorted, deduplicated value set. Throws `CronError` with codes
`CRON.UNSUPPORTED_SYNTAX`, `CRON.FIELD_OUT_OF_RANGE`, or
`CRON.INVALID_EXPRESSION` per the failure mode.
- `rangeFor(kind: FieldKind): FieldRange` — inclusive `{min, max}` for the
given field kind.
- `FieldKind` — `"second" | "minute" | "hour" | "dayOfMonth" | "month" |
"dayOfWeek" | "year"`.
- `FieldRange` — `{ readonly min: number; readonly max: number }`.
- `FieldAliases` — `Readonly<Record<string, number>>` (internal alias map
shape).
## Implementation Notes
The tokenizer walks comma-separated terms left-to-right, dispatching each
term through the step parser before falling through to the range parser.
Step extraction is bounded to a single `/` per term; nested steps
(`*/15/2`) are rejected up front. Range expansion is O(n) over the result
set and feeds a `Set` for deduplication before a final monotonically
sorted `Array.from`. Day-of-week values are normalized after expansion so
both `0,7` and `SAT,SUN` collapse correctly. Unsupported Quartz tokens
(`L`/`W`/`#`/`?`) are caught by a single regex before any further parsing
to keep the error path cheap; alias resolution is case-insensitive via
`toUpperCase`. The output array is `Object.freeze`d to prevent downstream
mutation.

View File

@@ -0,0 +1,78 @@
# expandMacro
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`expandMacro` recognises the conventional crontab macro shortcuts —
`@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`,
`@hourly` — and expands them to their canonical 5-field cron equivalents.
Macro expansion runs before field tokenization so the orchestrator only
has to know about the structural 5/6/7-field shape. The function is
deliberately conservative: it never silently rewrites non-macro input,
and it explicitly rejects `@reboot` (which has no wall-clock semantics in
a persistent process) with a typed `CRON.UNSUPPORTED_SYNTAX` error.
## Features
- Seven supported macros mapped to their POSIX-compatible 5-field
expansions.
- Case-insensitive (`@DAILY` and `@daily` are both accepted).
- Whitespace-tolerant (leading and trailing whitespace stripped).
- Returns `null` for non-macro inputs so the orchestrator can fall through
to the standard field tokenizer.
- Throws `CronError(CRON.UNSUPPORTED_SYNTAX)` for `@reboot`.
- Throws `CronError(CRON.INVALID_EXPRESSION)` for unknown `@` shortcuts.
## Usage: Basic Example
```typescript
import { expandMacro } from "@suaveplan/cron";
console.log(expandMacro("@daily")); // "0 0 * * *"
console.log(expandMacro("@hourly")); // "0 * * * *"
console.log(expandMacro("0 * * * *")); // null — not a macro
```
## Usage: Advanced Example
```typescript
import { expandMacro, CRON_CODES, CronError } from "@suaveplan/cron";
function canonicalise(input: string): string {
try {
return expandMacro(input) ?? input;
} catch (err) {
if (err instanceof CronError && err.code === CRON_CODES.UNSUPPORTED_SYNTAX) {
// Persistent process — fall back to "every 24 hours".
return "0 0 * * *";
}
throw err;
}
}
console.log(canonicalise("@reboot")); // "0 0 * * *"
console.log(canonicalise("@weekly")); // "0 0 * * 0"
```
## API Reference
- `expandMacro(expr: string): string | null` — returns the 5-field
expansion for a recognised macro, `null` for non-macro input, and
throws `CronError` for unknown or unsupported `@` shortcuts.
- `macroTable(): Readonly<Record<string, string>>` — returns the frozen
canonical lookup table used internally. Useful for documentation
generation and admin UIs that render the supported macro set.
## Implementation Notes
The macro table is `Object.freeze`d at module load to guard against
accidental mutation. Lookup is by lower-cased trimmed key so the function
is O(1). `@reboot` is explicitly intercepted before the table lookup
because its semantics differ fundamentally from the other macros — it
fires at process start, not at a wall-clock instant, and is therefore not
representable as a 5-field expansion. Returning `null` for non-macro
input (rather than throwing) is deliberate so the orchestrator can
unconditionally invoke `expandMacro` before its own field tokenizer
without paying for exception construction on the hot path.

View File

@@ -0,0 +1,99 @@
# next-run-fields
## Purpose
`next-run-fields` houses the low-level field-walking helpers used by the
forward and backward next-run search loops in `next-run.ts`. Splitting the
helpers into a sibling module keeps each file under the 300-line hard
limit while keeping the call sites in `next-run.ts` declarative — the
forward loop reads as `snapForward → fromWallClock → verify`, with the
fine-grained carry/rollover logic isolated here. None of these helpers
are part of the package's public API; they exist purely to support the
internal next-run engine.
## Features
- `climbFields(wall, fields)` — single forward step across seconds →
minutes → hours → days, honoring per-field carries.
- `climbFieldsBackward(wall, fields)` — mirror walk used by
`previousRun`.
- `advanceUntilMonthMatches`, `advanceUntilDayMatches`,
`advanceUntilYearMatches` — coarse jump helpers that snap a wall-clock
forward to the next legal month / day / year for the field set.
- `nextValueAtLeast` / `previousValueAtMost` — sorted-array lookup
helpers shared by every field walker.
- `copyWall` — converts an immutable `WallClock` to a `MutableWall` so
the search loop can mutate without aliasing.
## Usage: Basic Example
```typescript
import {
advanceUntilMonthMatches,
climbFields,
copyWall,
} from "@suaveplan/cron/next-run/next-run";
import { toWallClock } from "@suaveplan/cron";
const fields = {
minutes: [0],
hours: [9],
daysOfMonth: Array.from({ length: 31 }, (_, i) => i + 1),
months: [6, 12],
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
};
const wall = copyWall(toWallClock(Date.now(), "UTC"));
climbFields(wall, fields);
advanceUntilMonthMatches(wall, fields);
console.log(wall.month);
```
## Usage: Advanced Example
```typescript
import {
climbFieldsBackward,
copyWall,
} from "@suaveplan/cron/next-run/next-run";
import { toWallClock } from "@suaveplan/cron";
const fields = {
minutes: [0, 15, 30, 45],
hours: [9, 10, 11, 12, 13, 14, 15, 16, 17],
daysOfMonth: Array.from({ length: 31 }, (_, i) => i + 1),
months: Array.from({ length: 12 }, (_, i) => i + 1),
daysOfWeek: [1, 2, 3, 4, 5],
};
const wall = copyWall(toWallClock(Date.UTC(2026, 4, 11, 12, 47), "UTC"));
climbFieldsBackward(wall, fields);
console.log(wall.hour, wall.minute); // 12, 45
```
## API Reference
- `HORIZON_YEARS: 8`, `MS_PER_SECOND: 1000`, `MS_PER_MINUTE: 60000`.
- `MutableWall` — mutable counterpart of `WallClock` minus `weekday`.
- `nextValueAtLeast(sorted, current)`,
`previousValueAtMost(sorted, current)`.
- `daysInMonth(year, month)` — calendar-correct day count.
- `copyWall(w)`.
- `climbFields(wall, fields)`, `climbFieldsBackward(wall, fields)`.
- `advanceUntilMonthMatches`, `advanceUntilDayMatches`,
`advanceUntilYearMatches`, `yearMatches`.
## Implementation Notes
The forward and backward walkers are written as small step functions so
each carry case can be reasoned about independently. Day-of-month and
day-of-week interaction follows Vixie OR semantics: when one field is
the full range and the other is restricted, only the restricted field
matters; when both are restricted, either match is sufficient.
`advanceUntilDayMatches` walks one calendar day at a time so the OR
semantics fall out naturally rather than requiring a more elaborate
bitset intersection. Safety bounds in the loops cap iterations at
`366 × (HORIZON_YEARS + 1)` so pathological input cannot livelock the
search; the outer search functions in `next-run.ts` then convert the
resulting wall clock into an epoch instant and verify the result still
matches the field set, catching DST-gap edge cases.

View File

@@ -0,0 +1,77 @@
# next-run
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`computeNextRun` and `computePreviousRun` drive every public next-fire
query in `@suaveplan/cron`: they take a parsed `CronFields` set, a
reference epoch instant, and an IANA timezone, and return the closest
matching wall-clock instant in the requested direction. They implement
the bounded search horizon mandated by the spec — pathological
expressions (e.g. `0 0 31 2 *`, asking for February 31st) terminate with
`CronError(CRON.NEXT_RUN_NOT_FOUND)` rather than looping. DST transitions
are detected as the search crosses an offset boundary and emitted via
the OpenTelemetry counter.
## Features
- Bounded 8-year forward and backward horizons.
- DST-aware: results land on the next existing wall-clock instant during
spring-forward gaps and fire exactly once across fall-back overlaps
(first occurrence wins).
- OTel histograms + counters labelled by outcome and timezone.
- Vixie OR semantics for day-of-month vs. day-of-week handled at the
per-day check.
## Usage: Basic Example
```typescript
import { parseCron } from "@suaveplan/cron";
const expr = parseCron("0 9 * * 1-5");
const next = expr.nextRun(Date.UTC(2026, 4, 11, 5, 0, 0), "America/New_York");
console.log(new Date(next as number).toISOString());
```
## Usage: Advanced Example
```typescript
import { parseCron } from "@suaveplan/cron";
const expr = parseCron("0 2 * * *");
// 2026-03-08 in NY: clock jumps from 02:00 → 03:00. The 02:00 slot is
// skipped; the engine resolves to the next valid local instant.
const after = Date.UTC(2026, 2, 8, 6, 0, 0);
const next = expr.nextRun(after, "America/New_York");
console.log(new Date(next as number).toISOString());
// 2026-03-08T07:00:00.000Z — 03:00 local EDT.
```
## API Reference
- `computeNextRun(fields: CronFields, after: number, tz: string): number`
— throws `CronError(CRON.NEXT_RUN_NOT_FOUND)` on horizon exhaustion;
always returns a value strictly greater than `after`.
- `computePreviousRun(fields: CronFields, before: number, tz: string):
number` — mirror of the forward search. Same horizon and error.
## Implementation Notes
The forward search advances the cursor by one minute (or one second when
the seconds field is present) per iteration, snaps the wall-clock to the
next legal slot via the helpers in `next-run-fields.ts`, converts back
to epoch via `fromWallClock`, and re-reads the resulting wall-clock to
verify. The verify-after-snap loop is the key DST-handling primitive:
when the requested wall-clock falls into a spring-forward gap,
`fromWallClock` snaps to the first existing real instant, and the verify
step rejects that candidate if it no longer matches the expression — the
cursor then bumps forward by another minute and the loop retries until a
matching instant is found inside the 8-year horizon. Fall-back is handled
implicitly because we always pick the first epoch instant whose
wall-clock matches; the second occurrence of the repeated hour is then
strictly greater and not re-emitted. The backward search mirrors the
same pattern with a hard iteration cap (`366 × 24 × 60 × HORIZON_YEARS`)
so an expression with no historical match terminates rather than
livelocks.

View File

@@ -0,0 +1,80 @@
# cron-otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cron-otel` is the OpenTelemetry instrumentation surface for the
`@suaveplan/cron` package. It centralises every metric emitted by the
parser, the next-run engine, and the in-process scheduler so cardinality
stays bounded and label semantics stay consistent. Each helper is a thin,
no-throw wrapper around the global `metrics.getMeter("@suaveplan/cron")`
instruments and is safe to call on every hot-path invocation — the
underlying instrument is lazily constructed and then cached.
## Features
- Two histograms: `cron.parse.duration_ms` (outcome) and
`cron.next_run.duration_ms` (outcome + tz).
- Three counters: `cron.parse_total`, `cron.next_run_total`, and
`cron.dst_transition_total`.
- Closed-enum `outcome` and `kind` attributes to keep cardinality
bounded; `tz` is the caller-supplied IANA name pass-through.
- Lazy meter construction — no metrics work happens until first record.
## Usage: Basic Example
```typescript
import { recordParse, recordNextRun } from "@suaveplan/cron";
const t0 = Date.now();
try {
// …parse cron expression…
recordParse(Date.now() - t0, "success");
} catch (err) {
recordParse(Date.now() - t0, "error");
throw err;
}
recordNextRun(0.5, "success", "America/New_York");
```
## Usage: Advanced Example
```typescript
import { recordDstTransition } from "@suaveplan/cron";
function instrumentNextRun(detected: "spring-forward" | "fall-back" | null, tz: string) {
if (detected !== null) {
recordDstTransition(detected, tz);
}
}
instrumentNextRun("spring-forward", "America/New_York");
instrumentNextRun("fall-back", "Europe/London");
instrumentNextRun(null, "UTC"); // no-op
```
## API Reference
- `recordParse(durationMs: number, outcome: "success" | "error"): void`.
- `recordNextRun(durationMs: number, outcome: "success" | "error",
tz: string): void`.
- `recordDstTransition(kind: "spring-forward" | "fall-back",
tz: string): void`.
- `type Outcome = "success" | "error"` — exported helper alias.
## Implementation Notes
Each instrument is materialised through `metrics.getMeter(...)` only on
the first record call. Subsequent invocations reuse the module-scoped
cached handle, so the hot path is a single `record`/`add` call on the
shared instrument. The OpenTelemetry global meter provider is read each
time — that's a no-op when no provider is installed, which lets the
package ship metrics-ready without forcing consumers to wire up an SDK.
Attribute cardinality is held below practical limits: `outcome` and
`kind` are closed unions, and `tz` is passed through unmodified from the
caller (consumers control their own tz vocabulary). The package does not
emit logs or spans — those concerns live in higher-tier consumers like
`@suaveplan/job-scheduler`.

View File

@@ -0,0 +1,86 @@
# parseCron
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parseCron` is the top-level orchestrator that consumes a raw cron
expression string and returns a frozen `CronExpression` whose `nextRun`,
`nextRuns`, `previousRun`, and `matches` methods drive every scheduling
query. The orchestrator macro-expands the input first, then dispatches
the resulting tokens through `parseField` per position, applies optional
Vixie strict-DoW semantics, and finally binds the result to the search
engine in `next-run.ts`. `isValidCron` is the non-throwing sibling: it
returns a boolean and guarantees no exception escape for any string
input, as mandated by the spec.
## Features
- 5-field default; 6-field opt-in via `seconds: true`; 7-field Quartz
opt-in via both `seconds` and `years` true.
- Macro expansion (`@daily`, `@hourly`, ...) handled before tokenization.
- `strictDoW` option enforces "either dom or dow, never both" Vixie
semantics.
- `nextRuns(count)` produces a strictly monotonic array.
- `matches(when, tz)` is a constant-time membership check against the
parsed field sets.
## Usage: Basic Example
```typescript
import { parseCron, isValidCron } from "@suaveplan/cron";
const expr = parseCron("*/15 * * * *");
console.log(expr.fields.minutes); // [0, 15, 30, 45]
console.log(expr.nextRun(Date.UTC(2026, 4, 11, 10, 7, 0), "UTC"));
// Date.UTC(2026, 4, 11, 10, 15, 0)
console.log(isValidCron("not a cron expression")); // false
console.log(isValidCron("0 9 * * MON-FRI")); // true
```
## Usage: Advanced Example
```typescript
import { parseCron } from "@suaveplan/cron";
const quartz = parseCron("0 0 12 * * MON-FRI 2026-2030", {
seconds: true,
years: true,
});
const runs = quartz.nextRuns(3, Date.UTC(2026, 0, 1), "America/New_York");
console.log(runs.map((t) => new Date(t).toISOString()));
const strict = parseCron("0 12 15 * 1", { strictDoW: true });
// Rejects expressions where dom AND dow are both restricted.
console.log(strict.matches(Date.UTC(2026, 0, 15, 12, 0, 0), "UTC"));
```
## API Reference
- `parseCron(expr: string, options?: ParseOptions): CronExpression` —
parses and returns the frozen expression handle.
- `isValidCron(expr: string, options?: ParseOptions): boolean` — never
throws.
- `interface CronExpression { expr; fields; nextRun; nextRuns;
previousRun; matches }`.
- `interface ParseOptions { seconds?; years?; strictDoW? }`.
- `interface CronFields { seconds?; minutes; hours; daysOfMonth; months;
daysOfWeek; years? }`.
## Implementation Notes
The orchestrator first invokes `expandMacro` so the rest of the pipeline
only ever sees canonical 5/6/7-field strings. Tokenization is whitespace-
based with the field count strictly validated against `ParseOptions`.
Each field is independently parsed via the shared `parseField`
tokenizer, which returns a sorted, deduplicated `readonly number[]`.
`strictDoW` enforcement runs after tokenization so the error context
carries the actual parsed sets for debug visibility. The returned
`CronExpression` is `Object.freeze`d and binds its methods over the
captured fields, so destructuring (`const { nextRun } = parseCron(...)`)
is safe. `isValidCron` is a tight try/catch around the same code path so
the two functions stay in lock-step with no semantic drift. All parse
calls flow through `recordParse` so an outcome attribute lands on every
invocation regardless of success or failure.

View File

@@ -0,0 +1,109 @@
# CronScheduler
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CronScheduler` is a deliberately minimal in-process cron driver. It
parses each registered expression once via `parseCron`, schedules a
single `setTimeout` per job pointing at the next computed fire time, and
re-schedules immediately after each handler completes. Queueing,
retries, concurrency limits, persistence, and lifecycle hooks are
intentionally out of scope — that's `@suaveplan/job-scheduler`'s
responsibility. This class is small enough to embed inside a CLI, a
browser admin panel, or a serverless worker without dragging in the
full job-runner footprint.
## Features
- `add(expr, handler, options?)` returns a string id (auto-generated via
`crypto.randomUUID()` when omitted).
- `remove(id)` clears the timer and removes the job.
- `list()` snapshots every registered job with its next-run time.
- `start()` / `stop()` are idempotent; `stop()` awaits every in-flight
handler before resolving.
- Handler errors route to `options.onError` when provided, otherwise are
silently swallowed.
- Injectable `Clock` and injectable `{ setTimer, clearTimer }` pair for
fast, deterministic tests.
## Usage: Basic Example
```typescript
import { CronScheduler } from "@suaveplan/cron";
const scheduler = new CronScheduler();
scheduler.add("0 * * * *", async () => {
console.log("hourly tick");
});
scheduler.start();
// later…
await scheduler.stop();
```
## Usage: Advanced Example
```typescript
import { createCronScheduler } from "@suaveplan/cron";
let fakeNow = Date.UTC(2026, 4, 11, 9, 59, 0);
const pending = new Map<number, { cb: () => void; fireAt: number }>();
let nextTimerId = 1;
const scheduler = createCronScheduler({
clock: () => fakeNow,
timers: {
setTimer: (cb, ms) => {
const id = nextTimerId++;
pending.set(id, { cb, fireAt: fakeNow + ms });
return id;
},
clearTimer: (id) => pending.delete(id),
},
onError: (err, id) => console.error(`job ${id}`, err),
});
scheduler.add("0 * * * *", () => console.log("fired"));
scheduler.start();
// fast-forward
fakeNow = Date.UTC(2026, 4, 11, 10, 0, 1);
for (const [id, { cb, fireAt }] of pending) {
if (fireAt <= fakeNow) {
pending.delete(id);
cb();
}
}
await scheduler.stop();
```
## API Reference
- `class CronScheduler` with `constructor(options?: CronSchedulerOptions)`.
- `add(expr: string, handler: () => void | Promise<void>,
options?: CronSchedulerAddOptions): string`.
- `remove(id: string): boolean`.
- `list(): readonly CronSchedulerJob[]`.
- `start(): void`.
- `stop(): Promise<void>`.
- `createCronScheduler(options?): CronScheduler` — factory mirror.
- `CronSchedulerOptions`, `CronSchedulerAddOptions`, `CronSchedulerJob`,
`SchedulerTimers` types.
## Implementation Notes
Each job tracks at most one outstanding timer id. `stop()` walks every
job, clears its timer, and then `await`s the union of every promise in
the `inFlight` set, so handlers mid-execution always run to completion
before the returned promise resolves. Handler errors are caught inside
`invokeHandler` and forwarded to `onError`; the post-handler
re-scheduling continues unconditionally so a transient handler failure
does not stop the job from firing again. The injectable `timers` pair
is the testing seam: production callers get the real `setTimeout`, while
tests bind it to a `FakeClock` so the integration suite can fire several
hours of cron triggers in milliseconds. Job ids are auto-generated via
`crypto.randomUUID` when available, falling back to a timestamp-plus-
random string in environments that lack it, so the scheduler stays
universal.

View File

@@ -0,0 +1,88 @@
# timezone
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The timezone module converts between epoch-millisecond timestamps and the
wall-clock components observed in a specific IANA timezone using only the
platform-native `Intl.DateTimeFormat` — no external timezone database is
shipped or required. It powers the cron next-run algorithm by translating
the user's "every day at 02:00 New York time" into the actual moments
when that wall-clock instant occurs, including the awkward edge cases of
daylight-saving-time spring-forward gaps and fall-back overlaps.
## Features
- `toWallClock(epochMs, tz)` — decompose an epoch instant into year /
month / day / hour / minute / second / weekday in the requested zone.
- `fromWallClock(components, tz)` — the inverse; performs up to four
bounded fixed-point iterations to converge on the correct epoch even
across DST transitions.
- `detectDstTransition(before, after, tz)` — classifies a transition
between two adjacent instants as `"spring-forward"`, `"fall-back"`, or
`null` when no transition is crossed.
- `assertTimezone(tz)` — eagerly validates an IANA name so the caller can
fail fast at the API boundary.
- Per-zone formatter cache keeps Intl construction off the hot path.
## Usage: Basic Example
```typescript
import { toWallClock, fromWallClock } from "@suaveplan/cron";
const wall = toWallClock(Date.UTC(2026, 0, 1, 5, 0, 0), "America/New_York");
console.log(wall.hour); // 0 — midnight in NY
const back = fromWallClock(
{ year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 },
"America/New_York"
);
console.log(back === Date.UTC(2026, 0, 1, 5, 0, 0)); // true
```
## Usage: Advanced Example
```typescript
import {
detectDstTransition,
fromWallClock,
toWallClock,
} from "@suaveplan/cron";
const start = Date.UTC(2026, 2, 8, 6, 0, 0);
const end = Date.UTC(2026, 2, 8, 8, 0, 0);
const transition = detectDstTransition(start, end, "America/New_York");
console.log(transition); // "spring-forward"
const requested = { year: 2026, month: 3, day: 8, hour: 2, minute: 30, second: 0 };
const real = fromWallClock(requested, "America/New_York");
const observed = toWallClock(real, "America/New_York");
console.log(observed.hour, observed.minute); // not 02:30 — the slot was skipped
```
## API Reference
- `interface WallClock` — `{ year, month, day, hour, minute, second, weekday }`
with `weekday ∈ 0..6` (Sunday = 0, matching cron numbering).
- `toWallClock(epochMs: number, tz: string): WallClock`.
- `fromWallClock(components: Omit<WallClock, "weekday">, tz: string): number`.
- `detectDstTransition(beforeEpochMs, afterEpochMs, tz):
"spring-forward" | "fall-back" | null`.
- `assertTimezone(tz: string): void` — throws
`CronError(CRON.TIMEZONE_INVALID)` for invalid IANA names.
## Implementation Notes
`Intl.DateTimeFormat` is the only timezone authority. The package never
ships a tz database — keeping it universal and dependency-free. The
inverse `fromWallClock` works by guessing UTC, observing the wall clock
offset that produces, and applying it back. Two iterations suffice for
every IANA zone in normal time; we run up to four to handle the awkward
spring-forward case where the first correction overshoots the gap.
Inside the gap the function picks the canonical real-instant that
`Intl.DateTimeFormat` reports, and callers re-verify against their field
set so cron schedules that fall in the gap fire at the next existing
minute. Formatter construction is cached per IANA name to keep the hot
path allocation-free.