Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user