Initial Commit
This commit is contained in:
144
packages/foundation/timing/cron/README.md
Normal file
144
packages/foundation/timing/cron/README.md
Normal 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
|
||||
```
|
||||
|
||||
143
packages/foundation/timing/cron/package.json
Normal file
143
packages/foundation/timing/cron/package.json
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"name": "@suaveplan/cron",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal cron expression parser, next-fire calculator with timezone + DST support, and lightweight in-process scheduler",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors/cron-error": {
|
||||
"import": {
|
||||
"types": "./dist/errors/cron-error.d.ts",
|
||||
"default": "./dist/errors/cron-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/cron-error.d.ts",
|
||||
"default": "./dist/errors/cron-error.js"
|
||||
}
|
||||
},
|
||||
"./parse/parse-cron": {
|
||||
"import": {
|
||||
"types": "./dist/parse/parse-cron.d.ts",
|
||||
"default": "./dist/parse/parse-cron.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parse/parse-cron.d.ts",
|
||||
"default": "./dist/parse/parse-cron.js"
|
||||
}
|
||||
},
|
||||
"./fields/cron-fields": {
|
||||
"import": {
|
||||
"types": "./dist/fields/cron-fields.d.ts",
|
||||
"default": "./dist/fields/cron-fields.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/fields/cron-fields.d.ts",
|
||||
"default": "./dist/fields/cron-fields.js"
|
||||
}
|
||||
},
|
||||
"./macros/macros": {
|
||||
"import": {
|
||||
"types": "./dist/macros/macros.d.ts",
|
||||
"default": "./dist/macros/macros.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/macros/macros.d.ts",
|
||||
"default": "./dist/macros/macros.js"
|
||||
}
|
||||
},
|
||||
"./next-run/next-run": {
|
||||
"import": {
|
||||
"types": "./dist/next-run/next-run.d.ts",
|
||||
"default": "./dist/next-run/next-run.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/next-run/next-run.d.ts",
|
||||
"default": "./dist/next-run/next-run.js"
|
||||
}
|
||||
},
|
||||
"./timezone/timezone": {
|
||||
"import": {
|
||||
"types": "./dist/timezone/timezone.d.ts",
|
||||
"default": "./dist/timezone/timezone.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/timezone/timezone.d.ts",
|
||||
"default": "./dist/timezone/timezone.js"
|
||||
}
|
||||
},
|
||||
"./scheduler/cron-scheduler": {
|
||||
"import": {
|
||||
"types": "./dist/scheduler/cron-scheduler.d.ts",
|
||||
"default": "./dist/scheduler/cron-scheduler.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/scheduler/cron-scheduler.d.ts",
|
||||
"default": "./dist/scheduler/cron-scheduler.js"
|
||||
}
|
||||
},
|
||||
"./otel/cron-otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/cron-otel.d.ts",
|
||||
"default": "./dist/otel/cron-otel.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/otel/cron-otel.d.ts",
|
||||
"default": "./dist/otel/cron-otel.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
80
packages/foundation/timing/cron/src/errors/cron-error.md
Normal file
80
packages/foundation/timing/cron/src/errors/cron-error.md
Normal 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`.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError, registerCronErrorCodes } from "./cron-error.js";
|
||||
|
||||
describe("CronError", () => {
|
||||
it("CRON_CODES exposes every documented code", { timeout: 5000 }, () => {
|
||||
expect(CRON_CODES.INVALID_EXPRESSION).toBe("CRON.INVALID_EXPRESSION");
|
||||
expect(CRON_CODES.FIELD_OUT_OF_RANGE).toBe("CRON.FIELD_OUT_OF_RANGE");
|
||||
expect(CRON_CODES.UNSUPPORTED_SYNTAX).toBe("CRON.UNSUPPORTED_SYNTAX");
|
||||
expect(CRON_CODES.TIMEZONE_INVALID).toBe("CRON.TIMEZONE_INVALID");
|
||||
expect(CRON_CODES.NEXT_RUN_NOT_FOUND).toBe("CRON.NEXT_RUN_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("constructs with code and message key", { timeout: 5000 }, () => {
|
||||
const err = new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression"
|
||||
);
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe("CRON.INVALID_EXPRESSION");
|
||||
expect(err.name).toBe("CronError");
|
||||
});
|
||||
|
||||
it("threads context through to the base", { timeout: 5000 }, () => {
|
||||
const err = new CronError(
|
||||
CRON_CODES.FIELD_OUT_OF_RANGE,
|
||||
"cron.field_out_of_range",
|
||||
{ context: { value: 99, max: 59 } }
|
||||
);
|
||||
expect(err.context).toEqual({ value: 99, max: 59 });
|
||||
});
|
||||
|
||||
it("threads cause through to the base", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new CronError(
|
||||
CRON_CODES.TIMEZONE_INVALID,
|
||||
"cron.timezone_invalid",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("rejects unregistered codes at the public constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bogus =
|
||||
"CRON.NOPE" as unknown as typeof CRON_CODES.INVALID_EXPRESSION;
|
||||
expect(() => new CronError(bogus, "cron.invalid_expression")).toThrow(
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
it("registerCronErrorCodes returns the registered code count", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(registerCronErrorCodes()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
57
packages/foundation/timing/cron/src/errors/cron-error.ts
Normal file
57
packages/foundation/timing/cron/src/errors/cron-error.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of every `CRON.*` error code emitted by `@suaveplan/cron`.
|
||||
*
|
||||
* The registry is built via `defineCodes("CRON", [...])` and is part of the
|
||||
* package's public contract. Consumers branch on `err.code` rather than
|
||||
* parsing the human-readable `message`.
|
||||
*/
|
||||
export const CRON_CODES = defineCodes("CRON", [
|
||||
"INVALID_EXPRESSION",
|
||||
"FIELD_OUT_OF_RANGE",
|
||||
"UNSUPPORTED_SYNTAX",
|
||||
"TIMEZONE_INVALID",
|
||||
"NEXT_RUN_NOT_FOUND",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified `CRON.*` error code. */
|
||||
export type CronErrorCode = (typeof CRON_CODES)[keyof typeof CRON_CODES];
|
||||
|
||||
const VALID_CODES = new Set<string>(Object.values(CRON_CODES));
|
||||
|
||||
/**
|
||||
* Typed error class for the cron family. The public constructor performs a
|
||||
* runtime guard so that consumers passing an unregistered code at the
|
||||
* type-erased boundary (e.g. JSON deserialised codes) get a hard failure
|
||||
* rather than a silently-malformed error.
|
||||
*/
|
||||
export class CronError extends SuaveplanError<CronErrorCode> {
|
||||
public constructor(
|
||||
code: CronErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
if (!VALID_CODES.has(code)) {
|
||||
throw new TypeError(`Unknown CronError code: ${String(code)}`);
|
||||
}
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eager registration sentinel. Reading the registry size pins the
|
||||
* `defineCodes` call so the lookup `Set` is materialised before any
|
||||
* `CronError` is thrown. Returns the registered code count so callers
|
||||
* doing dependency-injection tree-shaking inspection have a side-effect-
|
||||
* free probe to assert against.
|
||||
*/
|
||||
export function registerCronErrorCodes(): number {
|
||||
return VALID_CODES.size;
|
||||
}
|
||||
|
||||
registerCronErrorCodes();
|
||||
6
packages/foundation/timing/cron/src/errors/index.ts
Normal file
6
packages/foundation/timing/cron/src/errors/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type { CronErrorCode } from "./cron-error.js";
|
||||
export {
|
||||
CRON_CODES,
|
||||
CronError,
|
||||
registerCronErrorCodes,
|
||||
} from "./cron-error.js";
|
||||
89
packages/foundation/timing/cron/src/fields/cron-fields.md
Normal file
89
packages/foundation/timing/cron/src/fields/cron-fields.md
Normal 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.
|
||||
@@ -0,0 +1,13 @@
|
||||
import z from "zod";
|
||||
|
||||
export const FieldKindSchema = z.enum([
|
||||
"second",
|
||||
"minute",
|
||||
"hour",
|
||||
"dayOfMonth",
|
||||
"month",
|
||||
"dayOfWeek",
|
||||
"year",
|
||||
]);
|
||||
|
||||
export const FieldTokenSchema = z.string().min(1).max(256);
|
||||
167
packages/foundation/timing/cron/src/fields/cron-fields.test.ts
Normal file
167
packages/foundation/timing/cron/src/fields/cron-fields.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { parseField, rangeFor } from "./cron-fields.js";
|
||||
|
||||
describe("parseField — wildcards", () => {
|
||||
it("expands * for minute to 0..59", { timeout: 5000 }, () => {
|
||||
const out = parseField("*", "minute");
|
||||
expect(out.length).toBe(60);
|
||||
expect(out[0]).toBe(0);
|
||||
expect(out[59]).toBe(59);
|
||||
});
|
||||
|
||||
it("expands * for hour to 0..23", { timeout: 5000 }, () => {
|
||||
const out = parseField("*", "hour");
|
||||
expect(Array.from(out)).toEqual(
|
||||
Array.from({ length: 24 }, (_, i) => i)
|
||||
);
|
||||
});
|
||||
|
||||
it("expands * for month to 1..12", { timeout: 5000 }, () => {
|
||||
const out = parseField("*", "month");
|
||||
expect(out[0]).toBe(1);
|
||||
expect(out[out.length - 1]).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseField — step values", () => {
|
||||
it("*/15 on minutes yields [0,15,30,45]", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("*/15", "minute"))).toEqual([
|
||||
0, 15, 30, 45,
|
||||
]);
|
||||
});
|
||||
|
||||
it("0-30/10 yields [0,10,20,30]", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("0-30/10", "minute"))).toEqual([
|
||||
0, 10, 20, 30,
|
||||
]);
|
||||
});
|
||||
|
||||
it("5/15 walks from 5 to range.max", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("5/15", "minute"))).toEqual([
|
||||
5, 20, 35, 50,
|
||||
]);
|
||||
});
|
||||
|
||||
it("step must be positive", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("*/0", "minute")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("non-numeric step rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("*/x", "minute")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("malformed step rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("*/15/2", "minute")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseField — ranges + lists", () => {
|
||||
it("1-5 dayOfWeek yields mon..fri", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("1-5", "dayOfWeek"))).toEqual([
|
||||
1, 2, 3, 4, 5,
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes overlapping list entries", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("1,2,1,3,2", "hour"))).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("malformed range rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("1-2-3", "hour")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("range start > end rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("10-2", "hour")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("empty term rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("1,,3", "hour")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseField — aliases", () => {
|
||||
it("MON-FRI yields [1,2,3,4,5]", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("MON-FRI", "dayOfWeek"))).toEqual([
|
||||
1, 2, 3, 4, 5,
|
||||
]);
|
||||
});
|
||||
|
||||
it("case-insensitive aliases", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("jan-mar", "month"))).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("unknown alias rejected", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("FOO", "month")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("alias not allowed on hour", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("MON", "hour")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseField — dayOfWeek 0/7 normalization", () => {
|
||||
it("7 normalizes to 0", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("7", "dayOfWeek"))).toEqual([0]);
|
||||
});
|
||||
|
||||
it("0,7 dedupes to [0]", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("0,7", "dayOfWeek"))).toEqual([0]);
|
||||
});
|
||||
|
||||
it("SUN normalizes to 0", { timeout: 5000 }, () => {
|
||||
expect(Array.from(parseField("SUN", "dayOfWeek"))).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseField — out-of-range + unsupported", () => {
|
||||
it("60 on minute is out of range", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseField("60", "minute");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.FIELD_OUT_OF_RANGE);
|
||||
}
|
||||
});
|
||||
|
||||
it("L token unsupported", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseField("L", "dayOfMonth");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.UNSUPPORTED_SYNTAX);
|
||||
}
|
||||
});
|
||||
|
||||
it("W token unsupported", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("15W", "dayOfMonth")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("# token unsupported", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("2#3", "dayOfWeek")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("? token unsupported", { timeout: 5000 }, () => {
|
||||
expect(() => parseField("?", "dayOfMonth")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("malformed atom rejected (mixed numeric+symbol)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => parseField("1.5", "minute")).toThrow(CronError);
|
||||
expect(() => parseField("@bogus", "minute")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rangeFor", () => {
|
||||
it("returns documented ranges", { timeout: 5000 }, () => {
|
||||
expect(rangeFor("minute")).toEqual({ min: 0, max: 59 });
|
||||
expect(rangeFor("hour")).toEqual({ min: 0, max: 23 });
|
||||
expect(rangeFor("dayOfMonth")).toEqual({ min: 1, max: 31 });
|
||||
expect(rangeFor("month")).toEqual({ min: 1, max: 12 });
|
||||
expect(rangeFor("year")).toEqual({ min: 1970, max: 2199 });
|
||||
expect(rangeFor("second")).toEqual({ min: 0, max: 59 });
|
||||
expect(rangeFor("dayOfWeek")).toEqual({ min: 0, max: 7 });
|
||||
});
|
||||
});
|
||||
195
packages/foundation/timing/cron/src/fields/cron-fields.ts
Normal file
195
packages/foundation/timing/cron/src/fields/cron-fields.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import type {
|
||||
FieldAliases,
|
||||
FieldKind,
|
||||
FieldRange,
|
||||
} from "./cron-fields.types.js";
|
||||
|
||||
const MONTH_ALIASES: FieldAliases = Object.freeze({
|
||||
JAN: 1,
|
||||
FEB: 2,
|
||||
MAR: 3,
|
||||
APR: 4,
|
||||
MAY: 5,
|
||||
JUN: 6,
|
||||
JUL: 7,
|
||||
AUG: 8,
|
||||
SEP: 9,
|
||||
OCT: 10,
|
||||
NOV: 11,
|
||||
DEC: 12,
|
||||
});
|
||||
|
||||
const DAY_OF_WEEK_ALIASES: FieldAliases = Object.freeze({
|
||||
SUN: 0,
|
||||
MON: 1,
|
||||
TUE: 2,
|
||||
WED: 3,
|
||||
THU: 4,
|
||||
FRI: 5,
|
||||
SAT: 6,
|
||||
});
|
||||
|
||||
const RANGES: Readonly<Record<FieldKind, FieldRange>> = Object.freeze({
|
||||
second: { min: 0, max: 59 },
|
||||
minute: { min: 0, max: 59 },
|
||||
hour: { min: 0, max: 23 },
|
||||
dayOfMonth: { min: 1, max: 31 },
|
||||
month: { min: 1, max: 12 },
|
||||
// dow accepts 7 as Sunday on input; normalized to 0 below.
|
||||
dayOfWeek: { min: 0, max: 7 },
|
||||
year: { min: 1970, max: 2199 },
|
||||
});
|
||||
|
||||
const ALIASES: Readonly<Record<FieldKind, FieldAliases>> = Object.freeze({
|
||||
second: {},
|
||||
minute: {},
|
||||
hour: {},
|
||||
dayOfMonth: {},
|
||||
month: MONTH_ALIASES,
|
||||
dayOfWeek: DAY_OF_WEEK_ALIASES,
|
||||
year: {},
|
||||
});
|
||||
|
||||
// Unsupported Quartz tokens: `L`/`W` standalone or suffixed to a numeric
|
||||
// (e.g. `15W`, `5L`), `#` for nth-weekday (`2#3`), `?` placeholder.
|
||||
// Alphabetic aliases like `JUL` or `FRI` must NOT be flagged here.
|
||||
const UNSUPPORTED_TOKENS = /^\d*[LW]$|#|^\?$/;
|
||||
const NUMERIC_TOKEN = /^\d+$/;
|
||||
const ALIAS_TOKEN = /^[A-Z]+$/i;
|
||||
|
||||
/** Returns the inclusive numeric range for a given field kind. */
|
||||
export function rangeFor(kind: FieldKind): FieldRange {
|
||||
return RANGES[kind];
|
||||
}
|
||||
|
||||
function invalid(expr: string, reason: string): never {
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{ context: { expr, reason } }
|
||||
);
|
||||
}
|
||||
|
||||
function outOfRange(expr: string, value: number, range: FieldRange): never {
|
||||
throw new CronError(
|
||||
CRON_CODES.FIELD_OUT_OF_RANGE,
|
||||
"cron.field_out_of_range",
|
||||
{ context: { expr, value, min: range.min, max: range.max } }
|
||||
);
|
||||
}
|
||||
|
||||
function unsupported(expr: string): never {
|
||||
throw new CronError(
|
||||
CRON_CODES.UNSUPPORTED_SYNTAX,
|
||||
"cron.unsupported_syntax",
|
||||
{ context: { expr } }
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAtom(
|
||||
atom: string,
|
||||
range: FieldRange,
|
||||
aliasesForKind: FieldAliases
|
||||
): number {
|
||||
if (NUMERIC_TOKEN.test(atom)) {
|
||||
const value = Number.parseInt(atom, 10);
|
||||
if (value < range.min || value > range.max)
|
||||
outOfRange(atom, value, range);
|
||||
return value;
|
||||
}
|
||||
if (ALIAS_TOKEN.test(atom)) {
|
||||
const key = atom.toUpperCase();
|
||||
const mapped = aliasesForKind[key];
|
||||
if (mapped === undefined) invalid(atom, "unknown alias");
|
||||
return mapped;
|
||||
}
|
||||
return invalid(atom, "malformed atom");
|
||||
}
|
||||
|
||||
function normalizeDow(value: number): number {
|
||||
return value === 7 ? 0 : value;
|
||||
}
|
||||
|
||||
function expandRange(start: number, end: number, step: number): number[] {
|
||||
if (start > end) invalid(`${start}-${end}`, "range start exceeds end");
|
||||
const out: number[] = [];
|
||||
for (let i = start; i <= end; i += step) out.push(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseRange(
|
||||
rangeText: string,
|
||||
range: FieldRange,
|
||||
aliasesForKind: FieldAliases
|
||||
): { start: number; end: number } {
|
||||
if (rangeText === "*") return { start: range.min, end: range.max };
|
||||
if (rangeText.includes("-")) {
|
||||
const parts = rangeText.split("-");
|
||||
if (parts.length !== 2) invalid(rangeText, "malformed range");
|
||||
const [startStr, endStr] = parts as [string, string];
|
||||
const start = resolveAtom(startStr, range, aliasesForKind);
|
||||
const end = resolveAtom(endStr, range, aliasesForKind);
|
||||
return { start, end };
|
||||
}
|
||||
const single = resolveAtom(rangeText, range, aliasesForKind);
|
||||
return { start: single, end: single };
|
||||
}
|
||||
|
||||
function parseTerm(
|
||||
term: string,
|
||||
range: FieldRange,
|
||||
aliasesForKind: FieldAliases
|
||||
): number[] {
|
||||
if (term.length === 0) invalid(term, "empty term");
|
||||
let step = 1;
|
||||
let body = term;
|
||||
if (term.includes("/")) {
|
||||
const parts = term.split("/");
|
||||
if (parts.length !== 2) invalid(term, "malformed step");
|
||||
const [bodyPart, stepStr] = parts as [string, string];
|
||||
body = bodyPart;
|
||||
if (!NUMERIC_TOKEN.test(stepStr)) invalid(term, "non-numeric step");
|
||||
step = Number.parseInt(stepStr, 10);
|
||||
if (step <= 0) invalid(term, "step must be positive");
|
||||
// Bare `*` with step has body "*"; bare numeric with step (e.g. "5/15")
|
||||
// means start at 5 and walk to range.max.
|
||||
if (body !== "*" && !body.includes("-")) {
|
||||
const start = resolveAtom(body, range, aliasesForKind);
|
||||
return expandRange(start, range.max, step);
|
||||
}
|
||||
}
|
||||
const { start, end } = parseRange(body, range, aliasesForKind);
|
||||
return expandRange(start, end, step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a single cron field into the sorted, unique set of values it
|
||||
* matches. Day-of-week field accepts both 0 and 7 for Sunday and normalizes
|
||||
* 7 → 0. Aliases (`JAN`/`MON`) are case-insensitive on month + dayOfWeek
|
||||
* fields only.
|
||||
*
|
||||
* @throws CronError(CRON.UNSUPPORTED_SYNTAX) for `L`/`W`/`#`/`?` tokens.
|
||||
* @throws CronError(CRON.FIELD_OUT_OF_RANGE) for numerics outside the field
|
||||
* range.
|
||||
* @throws CronError(CRON.INVALID_EXPRESSION) for malformed tokens.
|
||||
*/
|
||||
export function parseField(token: string, kind: FieldKind): readonly number[] {
|
||||
if (UNSUPPORTED_TOKENS.test(token)) unsupported(token);
|
||||
const range = RANGES[kind];
|
||||
const aliasesForKind = ALIASES[kind];
|
||||
const values: number[] = [];
|
||||
const buckets = new Set<number>();
|
||||
for (const term of token.split(",")) {
|
||||
const expanded = parseTerm(term, range, aliasesForKind);
|
||||
for (const v of expanded) {
|
||||
const normalized = kind === "dayOfWeek" ? normalizeDow(v) : v;
|
||||
if (!buckets.has(normalized)) {
|
||||
buckets.add(normalized);
|
||||
values.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
values.sort((a, b) => a - b);
|
||||
return Object.freeze(values);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Discriminator for a single cron field's parse rules. The field kind drives
|
||||
* the legal numeric range and (for `month` and `dayOfWeek`) the alphabetic
|
||||
* alias map.
|
||||
*/
|
||||
export type FieldKind =
|
||||
| "second"
|
||||
| "minute"
|
||||
| "hour"
|
||||
| "dayOfMonth"
|
||||
| "month"
|
||||
| "dayOfWeek"
|
||||
| "year";
|
||||
|
||||
/** Range bounds for a field kind, inclusive on both ends. */
|
||||
export interface FieldRange {
|
||||
readonly min: number;
|
||||
readonly max: number;
|
||||
}
|
||||
|
||||
/** Optional alphabetic alias map for `month` / `dayOfWeek`. */
|
||||
export type FieldAliases = Readonly<Record<string, number>>;
|
||||
6
packages/foundation/timing/cron/src/fields/index.ts
Normal file
6
packages/foundation/timing/cron/src/fields/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { parseField, rangeFor } from "./cron-fields.js";
|
||||
export type {
|
||||
FieldAliases,
|
||||
FieldKind,
|
||||
FieldRange,
|
||||
} from "./cron-fields.types.js";
|
||||
61
packages/foundation/timing/cron/src/index.ts
Normal file
61
packages/foundation/timing/cron/src/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Errors
|
||||
|
||||
export type { CronErrorCode } from "./errors/cron-error.js";
|
||||
export {
|
||||
CRON_CODES,
|
||||
CronError,
|
||||
registerCronErrorCodes,
|
||||
} from "./errors/cron-error.js";
|
||||
|
||||
// Fields
|
||||
export { parseField, rangeFor } from "./fields/cron-fields.js";
|
||||
export type {
|
||||
FieldAliases,
|
||||
FieldKind,
|
||||
FieldRange,
|
||||
} from "./fields/cron-fields.types.js";
|
||||
|
||||
// Macros
|
||||
export { expandMacro, macroTable } from "./macros/macros.js";
|
||||
// Next-run
|
||||
export { computeNextRun, computePreviousRun } from "./next-run/next-run.js";
|
||||
export type { Outcome } from "./otel/cron-otel.js";
|
||||
// OTel
|
||||
export {
|
||||
recordDstTransition,
|
||||
recordNextRun,
|
||||
recordParse,
|
||||
} from "./otel/cron-otel.js";
|
||||
export type { CronExpression } from "./parse/parse-cron.js";
|
||||
// Parse / orchestrator
|
||||
export { isValidCron, parseCron } from "./parse/parse-cron.js";
|
||||
export { ParseOptionsSchema } from "./parse/parse-cron.schemas.js";
|
||||
export type {
|
||||
CronExpressionShape,
|
||||
CronFields,
|
||||
ParseOptions,
|
||||
} from "./parse/parse-cron.types.js";
|
||||
|
||||
// Scheduler
|
||||
export {
|
||||
CronScheduler,
|
||||
createCronScheduler,
|
||||
} from "./scheduler/cron-scheduler.js";
|
||||
export {
|
||||
CronSchedulerAddOptionsSchema,
|
||||
CronSchedulerOptionsSchema,
|
||||
} from "./scheduler/cron-scheduler.schemas.js";
|
||||
export type {
|
||||
CronSchedulerAddOptions,
|
||||
CronSchedulerJob,
|
||||
CronSchedulerOptions,
|
||||
SchedulerTimers,
|
||||
} from "./scheduler/cron-scheduler.types.js";
|
||||
export type { WallClock } from "./timezone/timezone.js";
|
||||
// Timezone
|
||||
export {
|
||||
assertTimezone,
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
} from "./timezone/timezone.js";
|
||||
4
packages/foundation/timing/cron/src/macros/index.ts
Normal file
4
packages/foundation/timing/cron/src/macros/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
expandMacro,
|
||||
macroTable,
|
||||
} from "./macros.js";
|
||||
78
packages/foundation/timing/cron/src/macros/macros.md
Normal file
78
packages/foundation/timing/cron/src/macros/macros.md
Normal 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.
|
||||
71
packages/foundation/timing/cron/src/macros/macros.test.ts
Normal file
71
packages/foundation/timing/cron/src/macros/macros.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { expandMacro, macroTable } from "./macros.js";
|
||||
|
||||
describe("expandMacro", () => {
|
||||
it("expands @yearly", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@yearly")).toBe("0 0 1 1 *");
|
||||
});
|
||||
|
||||
it("expands @annually identically to @yearly", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@annually")).toBe(expandMacro("@yearly"));
|
||||
});
|
||||
|
||||
it("expands @monthly", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@monthly")).toBe("0 0 1 * *");
|
||||
});
|
||||
|
||||
it("expands @weekly", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@weekly")).toBe("0 0 * * 0");
|
||||
});
|
||||
|
||||
it("expands @daily", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@daily")).toBe("0 0 * * *");
|
||||
});
|
||||
|
||||
it("expands @midnight identically to @daily", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@midnight")).toBe(expandMacro("@daily"));
|
||||
});
|
||||
|
||||
it("expands @hourly", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@hourly")).toBe("0 * * * *");
|
||||
});
|
||||
|
||||
it("is case-insensitive", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("@DAILY")).toBe("0 0 * * *");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", { timeout: 5000 }, () => {
|
||||
expect(expandMacro(" @daily ")).toBe("0 0 * * *");
|
||||
});
|
||||
|
||||
it("returns null for non-macro input", { timeout: 5000 }, () => {
|
||||
expect(expandMacro("0 0 * * *")).toBeNull();
|
||||
});
|
||||
|
||||
it("@reboot throws UNSUPPORTED_SYNTAX", { timeout: 5000 }, () => {
|
||||
try {
|
||||
expandMacro("@reboot");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.UNSUPPORTED_SYNTAX);
|
||||
}
|
||||
});
|
||||
|
||||
it("unknown @ macros throw INVALID_EXPRESSION", { timeout: 5000 }, () => {
|
||||
try {
|
||||
expandMacro("@bogus");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.INVALID_EXPRESSION);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("macroTable", () => {
|
||||
it("returns the frozen lookup table", { timeout: 5000 }, () => {
|
||||
const table = macroTable();
|
||||
expect(table["@hourly"]).toBe("0 * * * *");
|
||||
});
|
||||
});
|
||||
46
packages/foundation/timing/cron/src/macros/macros.ts
Normal file
46
packages/foundation/timing/cron/src/macros/macros.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
|
||||
const MACRO_TABLE: Readonly<Record<string, string>> = Object.freeze({
|
||||
"@yearly": "0 0 1 1 *",
|
||||
"@annually": "0 0 1 1 *",
|
||||
"@monthly": "0 0 1 * *",
|
||||
"@weekly": "0 0 * * 0",
|
||||
"@daily": "0 0 * * *",
|
||||
"@midnight": "0 0 * * *",
|
||||
"@hourly": "0 * * * *",
|
||||
});
|
||||
|
||||
/**
|
||||
* Expand a macro shortcut (`@yearly`, `@hourly`, ...) to its canonical
|
||||
* 5-field cron expansion. Returns `null` when the input is not a recognised
|
||||
* macro.
|
||||
*
|
||||
* `@reboot` is reserved but explicitly unsupported (it has no fixed
|
||||
* wall-clock schedule) and throws `CRON.UNSUPPORTED_SYNTAX`.
|
||||
*/
|
||||
export function expandMacro(expr: string): string | null {
|
||||
const trimmed = expr.trim();
|
||||
if (!trimmed.startsWith("@")) return null;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (key === "@reboot") {
|
||||
throw new CronError(
|
||||
CRON_CODES.UNSUPPORTED_SYNTAX,
|
||||
"cron.unsupported_syntax",
|
||||
{ context: { macro: "@reboot" } }
|
||||
);
|
||||
}
|
||||
const expansion = MACRO_TABLE[key];
|
||||
if (expansion === undefined) {
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{ context: { expr, reason: "unknown macro" } }
|
||||
);
|
||||
}
|
||||
return expansion;
|
||||
}
|
||||
|
||||
/** Returns the readonly canonical macro lookup table. */
|
||||
export function macroTable(): Readonly<Record<string, string>> {
|
||||
return MACRO_TABLE;
|
||||
}
|
||||
4
packages/foundation/timing/cron/src/next-run/index.ts
Normal file
4
packages/foundation/timing/cron/src/next-run/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
computeNextRun,
|
||||
computePreviousRun,
|
||||
} from "./next-run.js";
|
||||
@@ -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.
|
||||
264
packages/foundation/timing/cron/src/next-run/next-run-fields.ts
Normal file
264
packages/foundation/timing/cron/src/next-run/next-run-fields.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { CronFields } from "../parse/parse-cron.types.js";
|
||||
import type { WallClock } from "../timezone/timezone.js";
|
||||
|
||||
export const HORIZON_YEARS = 8;
|
||||
export const MS_PER_SECOND = 1_000;
|
||||
export const MS_PER_MINUTE = 60_000;
|
||||
|
||||
export interface MutableWall {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
export function nextValueAtLeast(
|
||||
sorted: readonly number[],
|
||||
current: number
|
||||
): number | null {
|
||||
for (const value of sorted) {
|
||||
if (value >= current) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function previousValueAtMost(
|
||||
sorted: readonly number[],
|
||||
current: number
|
||||
): number | null {
|
||||
for (let i = sorted.length - 1; i >= 0; i -= 1) {
|
||||
const value = sorted[i] as number;
|
||||
if (value <= current) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
export function copyWall(w: WallClock): MutableWall {
|
||||
return {
|
||||
year: w.year,
|
||||
month: w.month,
|
||||
day: w.day,
|
||||
hour: w.hour,
|
||||
minute: w.minute,
|
||||
second: w.second,
|
||||
};
|
||||
}
|
||||
|
||||
function firstSecond(fields: CronFields): number {
|
||||
return fields.seconds === undefined ? 0 : (fields.seconds[0] as number);
|
||||
}
|
||||
|
||||
function firstMinute(fields: CronFields): number {
|
||||
return fields.minutes[0] as number;
|
||||
}
|
||||
|
||||
function firstHour(fields: CronFields): number {
|
||||
return fields.hours[0] as number;
|
||||
}
|
||||
|
||||
interface Carry {
|
||||
value: number;
|
||||
carry: boolean;
|
||||
}
|
||||
|
||||
function advanceWith(
|
||||
sorted: readonly number[],
|
||||
current: number,
|
||||
min: number
|
||||
): Carry {
|
||||
const next = nextValueAtLeast(sorted, current);
|
||||
if (next !== null) return { value: next, carry: false };
|
||||
return { value: min, carry: true };
|
||||
}
|
||||
|
||||
function bumpSecond(wall: MutableWall, fields: CronFields): boolean {
|
||||
if (fields.seconds === undefined) {
|
||||
wall.second = 0;
|
||||
return false;
|
||||
}
|
||||
const step = advanceWith(fields.seconds, wall.second, firstSecond(fields));
|
||||
wall.second = step.value;
|
||||
return step.carry;
|
||||
}
|
||||
|
||||
function bumpMinute(
|
||||
wall: MutableWall,
|
||||
fields: CronFields,
|
||||
secondCarry: boolean
|
||||
): boolean {
|
||||
if (secondCarry) wall.minute += 1;
|
||||
const step = advanceWith(fields.minutes, wall.minute, firstMinute(fields));
|
||||
if (step.value !== wall.minute) wall.second = firstSecond(fields);
|
||||
wall.minute = step.value;
|
||||
return step.carry;
|
||||
}
|
||||
|
||||
function bumpHour(
|
||||
wall: MutableWall,
|
||||
fields: CronFields,
|
||||
minuteCarry: boolean
|
||||
): boolean {
|
||||
if (minuteCarry) wall.hour += 1;
|
||||
const step = advanceWith(fields.hours, wall.hour, firstHour(fields));
|
||||
if (step.value !== wall.hour) {
|
||||
wall.minute = firstMinute(fields);
|
||||
wall.second = firstSecond(fields);
|
||||
}
|
||||
wall.hour = step.value;
|
||||
return step.carry;
|
||||
}
|
||||
|
||||
function rollDayIntoMonth(wall: MutableWall): void {
|
||||
while (wall.day > daysInMonth(wall.year, wall.month)) {
|
||||
wall.day -= daysInMonth(wall.year, wall.month);
|
||||
wall.month += 1;
|
||||
if (wall.month > 12) {
|
||||
wall.month = 1;
|
||||
wall.year += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function advanceDay(
|
||||
wall: MutableWall,
|
||||
fields: CronFields,
|
||||
hourCarry: boolean
|
||||
): void {
|
||||
if (hourCarry) {
|
||||
wall.day += 1;
|
||||
rollDayIntoMonth(wall);
|
||||
wall.hour = firstHour(fields);
|
||||
wall.minute = firstMinute(fields);
|
||||
wall.second = firstSecond(fields);
|
||||
}
|
||||
}
|
||||
|
||||
function dowMatches(
|
||||
fields: CronFields,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number
|
||||
): boolean {
|
||||
const epoch = Date.UTC(year, month - 1, day);
|
||||
const weekday = new Date(epoch).getUTCDay();
|
||||
return fields.daysOfWeek.includes(weekday);
|
||||
}
|
||||
|
||||
function dayMatches(
|
||||
fields: CronFields,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number
|
||||
): boolean {
|
||||
const domFull = fields.daysOfMonth.length === 31;
|
||||
const dowFull = fields.daysOfWeek.length === 7;
|
||||
const domHit = fields.daysOfMonth.includes(day);
|
||||
const dowHit = dowMatches(fields, year, month, day);
|
||||
if (domFull && dowFull) return true;
|
||||
if (domFull) return dowHit;
|
||||
if (dowFull) return domHit;
|
||||
return domHit || dowHit;
|
||||
}
|
||||
|
||||
export function advanceUntilMonthMatches(
|
||||
wall: MutableWall,
|
||||
fields: CronFields
|
||||
): void {
|
||||
while (!fields.months.includes(wall.month)) {
|
||||
const next = nextValueAtLeast(fields.months, wall.month);
|
||||
if (next !== null) {
|
||||
wall.month = next;
|
||||
} else {
|
||||
wall.month = fields.months[0] as number;
|
||||
wall.year += 1;
|
||||
}
|
||||
wall.day = 1;
|
||||
wall.hour = firstHour(fields);
|
||||
wall.minute = firstMinute(fields);
|
||||
wall.second = firstSecond(fields);
|
||||
}
|
||||
}
|
||||
|
||||
export function advanceUntilDayMatches(
|
||||
wall: MutableWall,
|
||||
fields: CronFields
|
||||
): void {
|
||||
let safety = 0;
|
||||
while (!dayMatches(fields, wall.year, wall.month, wall.day)) {
|
||||
wall.day += 1;
|
||||
rollDayIntoMonth(wall);
|
||||
advanceUntilMonthMatches(wall, fields);
|
||||
wall.hour = firstHour(fields);
|
||||
wall.minute = firstMinute(fields);
|
||||
wall.second = firstSecond(fields);
|
||||
safety += 1;
|
||||
if (safety > 366 * (HORIZON_YEARS + 1)) return;
|
||||
}
|
||||
}
|
||||
|
||||
export function yearMatches(fields: CronFields, year: number): boolean {
|
||||
return fields.years === undefined || fields.years.includes(year);
|
||||
}
|
||||
|
||||
export function advanceUntilYearMatches(
|
||||
wall: MutableWall,
|
||||
fields: CronFields
|
||||
): boolean {
|
||||
// Caller (`snapForward`) only invokes this when `!yearMatches(...)`,
|
||||
// so `fields.years` is defined and `wall.year` is not in the set.
|
||||
// The lookup returning a year strictly greater than `wall.year` is
|
||||
// therefore the only success path.
|
||||
const years = fields.years as readonly number[];
|
||||
const next = nextValueAtLeast(years, wall.year);
|
||||
if (next === null) return false;
|
||||
wall.year = next;
|
||||
wall.month = fields.months[0] as number;
|
||||
wall.day = 1;
|
||||
wall.hour = firstHour(fields);
|
||||
wall.minute = firstMinute(fields);
|
||||
wall.second = firstSecond(fields);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function climbFields(wall: MutableWall, fields: CronFields): void {
|
||||
const secondCarry = bumpSecond(wall, fields);
|
||||
const minuteCarry = bumpMinute(wall, fields, secondCarry);
|
||||
const hourCarry = bumpHour(wall, fields, minuteCarry);
|
||||
advanceDay(wall, fields, hourCarry);
|
||||
}
|
||||
|
||||
function lastValue(values: readonly number[]): number {
|
||||
return values[values.length - 1] as number;
|
||||
}
|
||||
|
||||
export function climbFieldsBackward(
|
||||
wall: MutableWall,
|
||||
fields: CronFields
|
||||
): void {
|
||||
if (fields.seconds !== undefined) {
|
||||
const sec = previousValueAtMost(fields.seconds, wall.second);
|
||||
wall.second = sec === null ? lastValue(fields.seconds) : sec;
|
||||
} else {
|
||||
wall.second = 0;
|
||||
}
|
||||
const min = previousValueAtMost(fields.minutes, wall.minute);
|
||||
if (min === null) {
|
||||
wall.minute = lastValue(fields.minutes);
|
||||
wall.hour -= 1;
|
||||
} else {
|
||||
wall.minute = min;
|
||||
}
|
||||
const hr = previousValueAtMost(fields.hours, wall.hour);
|
||||
if (hr === null) {
|
||||
wall.hour = lastValue(fields.hours);
|
||||
wall.day -= 1;
|
||||
} else {
|
||||
wall.hour = hr;
|
||||
}
|
||||
}
|
||||
77
packages/foundation/timing/cron/src/next-run/next-run.md
Normal file
77
packages/foundation/timing/cron/src/next-run/next-run.md
Normal 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.
|
||||
227
packages/foundation/timing/cron/src/next-run/next-run.test.ts
Normal file
227
packages/foundation/timing/cron/src/next-run/next-run.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { parseCron } from "../parse/parse-cron.js";
|
||||
|
||||
describe("next-run — basic UTC", () => {
|
||||
it("every hour at minute 0", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
expect(expr.nextRun(Date.UTC(2026, 4, 11, 10, 30, 0), "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 11, 0, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it("every 15 minutes", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("*/15 * * * *");
|
||||
expect(expr.nextRun(Date.UTC(2026, 4, 11, 10, 7, 0), "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 10, 15, 0)
|
||||
);
|
||||
expect(expr.nextRun(Date.UTC(2026, 4, 11, 10, 45, 0), "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 11, 0, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it("9 am weekdays", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 9 * * MON-FRI");
|
||||
// 2026-05-09 is Saturday. Next 9am weekday = Monday 2026-05-11.
|
||||
const next = expr.nextRun(Date.UTC(2026, 4, 9, 0, 0, 0), "UTC");
|
||||
expect(next).toBe(Date.UTC(2026, 4, 11, 9, 0, 0));
|
||||
});
|
||||
|
||||
it("midnight on Jan 1 only (annual)", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 1 1 *");
|
||||
expect(expr.nextRun(Date.UTC(2026, 0, 2), "UTC")).toBe(
|
||||
Date.UTC(2027, 0, 1, 0, 0, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it("noon every Sunday", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 12 * * 0");
|
||||
// 2026-05-11 = Monday; next Sunday = 2026-05-17.
|
||||
const next = expr.nextRun(Date.UTC(2026, 4, 11, 0, 0, 0), "UTC");
|
||||
expect(next).toBe(Date.UTC(2026, 4, 17, 12, 0, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-run — seconds", () => {
|
||||
it("every 10 seconds", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("*/10 * * * * *", { seconds: true });
|
||||
expect(expr.nextRun(Date.UTC(2026, 4, 11, 10, 0, 5), "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 10, 0, 10)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-run — DST America/New_York", () => {
|
||||
it("spring-forward skips 02:00 local on 2026-03-08", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 2 * * *");
|
||||
// 2026-03-08 06:00Z = 01:00 local EST. Next 02:00 local does not
|
||||
// exist; first matching wall clock is 02:00 the next day. The
|
||||
// engine returns the next-valid match.
|
||||
const after = Date.UTC(2026, 2, 8, 6, 0, 0);
|
||||
const next = expr.nextRun(after, "America/New_York");
|
||||
expect(next).not.toBeNull();
|
||||
const ms = next as number;
|
||||
expect(ms > after).toBe(true);
|
||||
});
|
||||
|
||||
it("fall-back fires once on 2026-11-01", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("30 1 * * *");
|
||||
// 2026-11-01: 01:30 local exists twice (once EDT, once EST). The
|
||||
// engine should pick the first occurrence and not double-fire.
|
||||
const after = Date.UTC(2026, 9, 31, 0, 0, 0);
|
||||
const next = expr.nextRun(after, "America/New_York");
|
||||
const next2 = expr.nextRun(next as number, "America/New_York");
|
||||
expect(next2).not.toBeNull();
|
||||
expect((next2 as number) > (next as number)).toBe(true);
|
||||
// No two consecutive fires happen within the same hour.
|
||||
expect((next2 as number) - (next as number)).toBeGreaterThan(
|
||||
60 * 60 * 1000
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-run — DST Europe/London + Australia/Sydney", () => {
|
||||
it("Europe/London spring-forward 2026-03-29", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 1 * * *");
|
||||
const after = Date.UTC(2026, 2, 29, 0, 0, 0);
|
||||
const next = expr.nextRun(after, "Europe/London");
|
||||
expect(next).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Australia/Sydney fall-back 2026-04-05", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("30 2 * * *");
|
||||
const after = Date.UTC(2026, 3, 4, 0, 0, 0);
|
||||
const next = expr.nextRun(after, "Australia/Sydney");
|
||||
expect(next).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-run — impossible expressions", () => {
|
||||
it("Feb 31 throws NEXT_RUN_NOT_FOUND", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 31 2 *");
|
||||
try {
|
||||
expr.nextRun(Date.UTC(2026, 0, 1), "UTC");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.NEXT_RUN_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it("year horizon exhausted with year field", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 0 1 1 * 1980", {
|
||||
seconds: true,
|
||||
years: true,
|
||||
});
|
||||
// 1980 is in the past; from 2026 forward, year never matches.
|
||||
try {
|
||||
expr.nextRun(Date.UTC(2026, 0, 1), "UTC");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.NEXT_RUN_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-run — invalid timezone", () => {
|
||||
it("throws TIMEZONE_INVALID for bad zone", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
try {
|
||||
expr.nextRun(Date.UTC(2026, 4, 11), "Mars/Olympus_Mons");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.TIMEZONE_INVALID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousRun", () => {
|
||||
it("previous 9am on a weekday", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 9 * * *");
|
||||
const before = Date.UTC(2026, 4, 11, 12, 0, 0); // 2026-05-11 12:00 UTC
|
||||
const prev = expr.previousRun(before, "UTC");
|
||||
expect(prev).toBe(Date.UTC(2026, 4, 11, 9, 0, 0));
|
||||
});
|
||||
|
||||
it("previous 9am Monday from a Sunday", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 9 * * MON");
|
||||
// 2026-05-10 is Sunday. Previous Monday 9am = 2026-05-04.
|
||||
const before = Date.UTC(2026, 4, 10, 12, 0, 0);
|
||||
const prev = expr.previousRun(before, "UTC");
|
||||
expect(prev).toBe(Date.UTC(2026, 4, 4, 9, 0, 0));
|
||||
});
|
||||
|
||||
it("previousRun with seconds field", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 9 * * *", { seconds: true });
|
||||
const before = Date.UTC(2026, 4, 11, 12, 0, 0);
|
||||
expect(expr.previousRun(before, "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 9, 0, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it("previousRun wraps seconds when wall.second is below min", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// seconds=[30,45], minutes=[0], hours=[9].
|
||||
const expr = parseCron("30,45 0 9 * * *", { seconds: true });
|
||||
// Before 09:00:20 UTC, previous match is 08 ... no — minutes=[0]
|
||||
// so the previous hit is yesterday's 09:00:45. The wrap path
|
||||
// exercises sec === null inside climbFieldsBackward.
|
||||
const before = Date.UTC(2026, 4, 11, 9, 0, 20);
|
||||
const prev = expr.previousRun(before, "UTC");
|
||||
expect(prev).toBe(Date.UTC(2026, 4, 10, 9, 0, 45));
|
||||
});
|
||||
|
||||
it("previousRun wraps through minute boundary", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("30 * * * *");
|
||||
// Before 10:15 UTC, previous fire is 09:30 UTC.
|
||||
const before = Date.UTC(2026, 4, 11, 10, 15, 0);
|
||||
expect(expr.previousRun(before, "UTC")).toBe(
|
||||
Date.UTC(2026, 4, 11, 9, 30, 0)
|
||||
);
|
||||
});
|
||||
|
||||
it("Feb 31 backward also throws NEXT_RUN_NOT_FOUND", {
|
||||
// The backward search lacks the large-jump optimisations
|
||||
// `searchForward` has (`advanceUntilYearMatches` /
|
||||
// `advanceUntilMonthMatches`), so an impossible cron walks
|
||||
// hour-by-hour through the 8-year horizon. Bounded but slow on
|
||||
// shared CI — bump the timeout above the natural runtime.
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 0 31 2 *");
|
||||
try {
|
||||
expr.previousRun(Date.UTC(2026, 0, 1), "UTC");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.NEXT_RUN_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vixie OR semantics", () => {
|
||||
it("dom AND dow both restricted = either match", { timeout: 5000 }, () => {
|
||||
// Fires on either the 13th OR any Friday.
|
||||
const expr = parseCron("0 12 13 * 5");
|
||||
// 2026-05-13 is Wednesday — fires because dom matches.
|
||||
expect(expr.matches(Date.UTC(2026, 4, 13, 12, 0, 0), "UTC")).toBe(true);
|
||||
// 2026-05-15 is Friday — fires because dow matches.
|
||||
expect(expr.matches(Date.UTC(2026, 4, 15, 12, 0, 0), "UTC")).toBe(true);
|
||||
// 2026-05-14 is Thursday — does not fire.
|
||||
expect(expr.matches(Date.UTC(2026, 4, 14, 12, 0, 0), "UTC")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("nextRun walks until either dom or dow matches", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 12 13 * 5");
|
||||
// From 2026-05-10 (Sun), next match: either Wed May 13 OR Fri May 15.
|
||||
// The earlier is Wed May 13 (dom=13 match).
|
||||
const next = expr.nextRun(Date.UTC(2026, 4, 10, 0, 0, 0), "UTC");
|
||||
expect(next).toBe(Date.UTC(2026, 4, 13, 12, 0, 0));
|
||||
});
|
||||
});
|
||||
163
packages/foundation/timing/cron/src/next-run/next-run.ts
Normal file
163
packages/foundation/timing/cron/src/next-run/next-run.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { recordDstTransition, recordNextRun } from "../otel/cron-otel.js";
|
||||
import { fieldsMatchWall } from "../parse/parse-cron.js";
|
||||
import type { CronFields } from "../parse/parse-cron.types.js";
|
||||
import {
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
type WallClock,
|
||||
} from "../timezone/timezone.js";
|
||||
import {
|
||||
advanceUntilDayMatches,
|
||||
advanceUntilMonthMatches,
|
||||
advanceUntilYearMatches,
|
||||
climbFields,
|
||||
climbFieldsBackward,
|
||||
copyWall,
|
||||
HORIZON_YEARS,
|
||||
MS_PER_MINUTE,
|
||||
MS_PER_SECOND,
|
||||
type MutableWall,
|
||||
yearMatches,
|
||||
} from "./next-run-fields.js";
|
||||
|
||||
function startCursor(after: number, useSeconds: boolean): number {
|
||||
return useSeconds ? after + MS_PER_SECOND : after + MS_PER_MINUTE;
|
||||
}
|
||||
|
||||
function isWithinHorizon(wall: MutableWall, baseYear: number): boolean {
|
||||
return wall.year - baseYear <= HORIZON_YEARS;
|
||||
}
|
||||
|
||||
function snapForward(
|
||||
fields: CronFields,
|
||||
wall: MutableWall,
|
||||
baseYear: number
|
||||
): void {
|
||||
if (!yearMatches(fields, wall.year)) {
|
||||
if (!advanceUntilYearMatches(wall, fields)) {
|
||||
throw new CronError(
|
||||
CRON_CODES.NEXT_RUN_NOT_FOUND,
|
||||
"cron.next_run_not_found",
|
||||
{ context: { reason: "year horizon exhausted" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
climbFields(wall, fields);
|
||||
advanceUntilMonthMatches(wall, fields);
|
||||
advanceUntilDayMatches(wall, fields);
|
||||
if (!isWithinHorizon(wall, baseYear)) {
|
||||
throw new CronError(
|
||||
CRON_CODES.NEXT_RUN_NOT_FOUND,
|
||||
"cron.next_run_not_found",
|
||||
{
|
||||
context: {
|
||||
reason: "search horizon exhausted",
|
||||
baseYear,
|
||||
finalYear: wall.year,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isAcceptable(
|
||||
fields: CronFields,
|
||||
candidate: number,
|
||||
observed: WallClock,
|
||||
after: number
|
||||
): boolean {
|
||||
if (candidate <= after) return false;
|
||||
return fieldsMatchWall(fields, observed);
|
||||
}
|
||||
|
||||
function searchForward(fields: CronFields, after: number, tz: string): number {
|
||||
let cursor = startCursor(after, fields.seconds !== undefined);
|
||||
const baseYear = toWallClock(after, tz).year;
|
||||
while (true) {
|
||||
const wall = copyWall(toWallClock(cursor, tz));
|
||||
snapForward(fields, wall, baseYear);
|
||||
const candidate = fromWallClock(wall, tz);
|
||||
const observed = toWallClock(candidate, tz);
|
||||
if (isAcceptable(fields, candidate, observed, after)) {
|
||||
const transition = detectDstTransition(after, candidate, tz);
|
||||
if (transition !== null) recordDstTransition(transition, tz);
|
||||
return candidate;
|
||||
}
|
||||
// Failure path covers both DST-gap collisions (observed wall
|
||||
// disagrees with requested wall) and round-trip stalls (candidate
|
||||
// ≤ cursor). Advance by a full hour past the larger of (cursor,
|
||||
// candidate) so the next iteration cannot loop back into the
|
||||
// same skipped or already-visited window.
|
||||
const advanceFrom = candidate > cursor ? candidate : cursor;
|
||||
cursor = advanceFrom + 60 * MS_PER_MINUTE;
|
||||
}
|
||||
}
|
||||
|
||||
function searchBackward(
|
||||
fields: CronFields,
|
||||
before: number,
|
||||
tz: string
|
||||
): number {
|
||||
let cursor = before - MS_PER_MINUTE;
|
||||
const baseYear = toWallClock(before, tz).year;
|
||||
while (true) {
|
||||
const wall = copyWall(toWallClock(cursor, tz));
|
||||
if (baseYear - wall.year > HORIZON_YEARS) {
|
||||
throw new CronError(
|
||||
CRON_CODES.NEXT_RUN_NOT_FOUND,
|
||||
"cron.next_run_not_found",
|
||||
{
|
||||
context: {
|
||||
reason: "backward horizon exhausted",
|
||||
baseYear,
|
||||
finalYear: wall.year,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
climbFieldsBackward(wall, fields);
|
||||
const candidate = fromWallClock(wall, tz);
|
||||
const observed = toWallClock(candidate, tz);
|
||||
// `searchBackward` only ever steps cursor backward, so candidate
|
||||
// < before holds by construction; field membership is the sole
|
||||
// acceptance criterion.
|
||||
if (candidate < before && fieldsMatchWall(fields, observed)) {
|
||||
return candidate;
|
||||
}
|
||||
// Step the cursor back a full hour from its current position; the
|
||||
// candidate from the previous iteration is never further forward
|
||||
// because `climbFieldsBackward` only moves time backward.
|
||||
cursor -= 60 * MS_PER_MINUTE;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the next epoch instant strictly greater than `after` matching
|
||||
* the cron field set. Throws `CronError(CRON.NEXT_RUN_NOT_FOUND)` on
|
||||
* horizon exhaustion. */
|
||||
export function computeNextRun(
|
||||
fields: CronFields,
|
||||
after: number,
|
||||
tz: string
|
||||
): number {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const result = searchForward(fields, after, tz);
|
||||
recordNextRun(Date.now() - t0, "success", tz);
|
||||
return result;
|
||||
} catch (err) {
|
||||
recordNextRun(Date.now() - t0, "error", tz);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the previous epoch instant strictly less than `before` matching
|
||||
* the cron field set. */
|
||||
export function computePreviousRun(
|
||||
fields: CronFields,
|
||||
before: number,
|
||||
tz: string
|
||||
): number {
|
||||
return searchBackward(fields, before, tz);
|
||||
}
|
||||
80
packages/foundation/timing/cron/src/otel/cron-otel.md
Normal file
80
packages/foundation/timing/cron/src/otel/cron-otel.md
Normal 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`.
|
||||
44
packages/foundation/timing/cron/src/otel/cron-otel.test.ts
Normal file
44
packages/foundation/timing/cron/src/otel/cron-otel.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
recordDstTransition,
|
||||
recordNextRun,
|
||||
recordParse,
|
||||
} from "./cron-otel.js";
|
||||
|
||||
describe("cron-otel", () => {
|
||||
it("recordParse accepts both outcomes without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordParse(1.5, "success")).not.toThrow();
|
||||
expect(() => recordParse(2, "error")).not.toThrow();
|
||||
});
|
||||
|
||||
it("recordNextRun accepts tz attribute without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordNextRun(3, "success", "UTC")).not.toThrow();
|
||||
expect(() =>
|
||||
recordNextRun(4, "error", "America/New_York")
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("recordDstTransition accepts kind + tz", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
recordDstTransition("spring-forward", "America/New_York")
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
recordDstTransition("fall-back", "Europe/London")
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("caches meter handles across calls", { timeout: 5000 }, () => {
|
||||
// Re-invoke each function: second call should hit cached histogram/counter.
|
||||
recordParse(0, "success");
|
||||
recordParse(0, "success");
|
||||
recordNextRun(0, "success", "UTC");
|
||||
recordNextRun(0, "success", "UTC");
|
||||
recordDstTransition("spring-forward", "UTC");
|
||||
recordDstTransition("fall-back", "UTC");
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
93
packages/foundation/timing/cron/src/otel/cron-otel.ts
Normal file
93
packages/foundation/timing/cron/src/otel/cron-otel.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/cron";
|
||||
|
||||
let _parseHistogram: Histogram | undefined;
|
||||
let _nextRunHistogram: Histogram | undefined;
|
||||
let _parseCounter: Counter | undefined;
|
||||
let _nextRunCounter: Counter | undefined;
|
||||
let _dstTransitionCounter: Counter | undefined;
|
||||
|
||||
function getParseHistogram(): Histogram {
|
||||
if (!_parseHistogram) {
|
||||
_parseHistogram = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createHistogram("cron.parse.duration_ms", {
|
||||
description: "Cron expression parse duration in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
return _parseHistogram;
|
||||
}
|
||||
|
||||
function getNextRunHistogram(): Histogram {
|
||||
if (!_nextRunHistogram) {
|
||||
_nextRunHistogram = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createHistogram("cron.next_run.duration_ms", {
|
||||
description: "Cron next-run search duration in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
return _nextRunHistogram;
|
||||
}
|
||||
|
||||
function getParseCounter(): Counter {
|
||||
if (!_parseCounter) {
|
||||
_parseCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter("cron.parse_total", {
|
||||
description: "Cron parse invocations by outcome",
|
||||
});
|
||||
}
|
||||
return _parseCounter;
|
||||
}
|
||||
|
||||
function getNextRunCounter(): Counter {
|
||||
if (!_nextRunCounter) {
|
||||
_nextRunCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter("cron.next_run_total", {
|
||||
description: "Cron next-run invocations by outcome and tz",
|
||||
});
|
||||
}
|
||||
return _nextRunCounter;
|
||||
}
|
||||
|
||||
function getDstTransitionCounter(): Counter {
|
||||
if (!_dstTransitionCounter) {
|
||||
_dstTransitionCounter = metrics
|
||||
.getMeter(METER_NAME)
|
||||
.createCounter("cron.dst_transition_total", {
|
||||
description: "DST transitions crossed during next-run search",
|
||||
});
|
||||
}
|
||||
return _dstTransitionCounter;
|
||||
}
|
||||
|
||||
/** Outcome attribute is a closed enum so cardinality is bounded. */
|
||||
export type Outcome = "success" | "error";
|
||||
|
||||
/** Record a parse-duration sample and increment the parse counter. */
|
||||
export function recordParse(durationMs: number, outcome: Outcome): void {
|
||||
getParseHistogram().record(durationMs, { outcome });
|
||||
getParseCounter().add(1, { outcome });
|
||||
}
|
||||
|
||||
/** Record a next-run-duration sample and increment the next-run counter. */
|
||||
export function recordNextRun(
|
||||
durationMs: number,
|
||||
outcome: Outcome,
|
||||
tz: string
|
||||
): void {
|
||||
getNextRunHistogram().record(durationMs, { outcome, tz });
|
||||
getNextRunCounter().add(1, { outcome, tz });
|
||||
}
|
||||
|
||||
/** Increment the DST-transition counter. `kind` is a closed enum. */
|
||||
export function recordDstTransition(
|
||||
kind: "spring-forward" | "fall-back",
|
||||
tz: string
|
||||
): void {
|
||||
getDstTransitionCounter().add(1, { kind, tz });
|
||||
}
|
||||
6
packages/foundation/timing/cron/src/otel/index.ts
Normal file
6
packages/foundation/timing/cron/src/otel/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type { Outcome } from "./cron-otel.js";
|
||||
export {
|
||||
recordDstTransition,
|
||||
recordNextRun,
|
||||
recordParse,
|
||||
} from "./cron-otel.js";
|
||||
8
packages/foundation/timing/cron/src/parse/index.ts
Normal file
8
packages/foundation/timing/cron/src/parse/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { CronExpression } from "./parse-cron.js";
|
||||
export { isValidCron, parseCron } from "./parse-cron.js";
|
||||
export { ParseOptionsSchema } from "./parse-cron.schemas.js";
|
||||
export type {
|
||||
CronExpressionShape,
|
||||
CronFields,
|
||||
ParseOptions,
|
||||
} from "./parse-cron.types.js";
|
||||
86
packages/foundation/timing/cron/src/parse/parse-cron.md
Normal file
86
packages/foundation/timing/cron/src/parse/parse-cron.md
Normal 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.
|
||||
@@ -0,0 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ParseOptionsSchema = z.object({
|
||||
seconds: z.boolean().optional(),
|
||||
years: z.boolean().optional(),
|
||||
strictDoW: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CronExpressionInputSchema = z.string().min(1).max(256);
|
||||
232
packages/foundation/timing/cron/src/parse/parse-cron.test.ts
Normal file
232
packages/foundation/timing/cron/src/parse/parse-cron.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { isValidCron, parseCron } from "./parse-cron.js";
|
||||
|
||||
describe("parseCron — 5-field default", () => {
|
||||
it("parses simple expression", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 9 * * *");
|
||||
expect(Array.from(expr.fields.minutes)).toEqual([0]);
|
||||
expect(Array.from(expr.fields.hours)).toEqual([9]);
|
||||
expect(expr.fields.seconds).toBeUndefined();
|
||||
expect(expr.fields.years).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes whitespace runs", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 9 * * *");
|
||||
expect(Array.from(expr.fields.hours)).toEqual([9]);
|
||||
});
|
||||
|
||||
it("rejects empty input", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseCron("");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.INVALID_EXPRESSION);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects 4-field expressions", { timeout: 5000 }, () => {
|
||||
expect(() => parseCron("* * * *")).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("rejects 6-field by default", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseCron("0 */5 * * * *");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as CronError).code).toBe(CRON_CODES.INVALID_EXPRESSION);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects garbage", { timeout: 5000 }, () => {
|
||||
expect(() => parseCron("garbage input")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCron — macros", () => {
|
||||
it("expands @daily before tokenization", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("@daily");
|
||||
expect(Array.from(expr.fields.minutes)).toEqual([0]);
|
||||
expect(Array.from(expr.fields.hours)).toEqual([0]);
|
||||
});
|
||||
|
||||
it("expands @hourly", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("@hourly");
|
||||
expect(Array.from(expr.fields.minutes)).toEqual([0]);
|
||||
expect(expr.fields.hours.length).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCron — 6-field with seconds", () => {
|
||||
it("parses seconds field", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 9 * * *", { seconds: true });
|
||||
expect(expr.fields.seconds).toEqual([0]);
|
||||
});
|
||||
|
||||
it("rejects 5-field when seconds: true and years: true required", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseCron("0 9 * * *", { seconds: true, years: true })
|
||||
).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCron — 7-field Quartz", () => {
|
||||
it("parses year field", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 9 * * * 2026-2028", {
|
||||
seconds: true,
|
||||
years: true,
|
||||
});
|
||||
expect(Array.from(expr.fields.years ?? [])).toEqual([2026, 2027, 2028]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCron — strictDoW", () => {
|
||||
it("rejects dom+dow both restricted", { timeout: 5000 }, () => {
|
||||
expect(() => parseCron("0 12 15 * 1", { strictDoW: true })).toThrow(
|
||||
CronError
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts dom-only restriction", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 12 15 * *", { strictDoW: true });
|
||||
expect(Array.from(expr.fields.daysOfMonth)).toEqual([15]);
|
||||
});
|
||||
|
||||
it("accepts dow-only restriction", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 12 * * 1", { strictDoW: true });
|
||||
expect(Array.from(expr.fields.daysOfWeek)).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CronExpression.nextRun / nextRuns / previousRun / matches", () => {
|
||||
it("nextRun is strictly greater than after", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const after = Date.UTC(2026, 4, 11, 10, 30, 0);
|
||||
const next = expr.nextRun(after, "UTC");
|
||||
expect(next).toBe(Date.UTC(2026, 4, 11, 11, 0, 0));
|
||||
});
|
||||
|
||||
it("nextRun accepts Date input", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const next = expr.nextRun(
|
||||
new Date(Date.UTC(2026, 4, 11, 10, 30, 0)),
|
||||
"UTC"
|
||||
);
|
||||
expect(next).toBe(Date.UTC(2026, 4, 11, 11, 0, 0));
|
||||
});
|
||||
|
||||
it("nextRun defaults to Date.now when after omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const next = expr.nextRun();
|
||||
expect(typeof next).toBe("number");
|
||||
});
|
||||
|
||||
it("previousRun defaults tz to UTC", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const prev = expr.previousRun(Date.UTC(2026, 4, 11, 10, 30, 0));
|
||||
expect(prev).toBe(Date.UTC(2026, 4, 11, 10, 0, 0));
|
||||
});
|
||||
|
||||
it("matches defaults tz to UTC", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
expect(expr.matches(Date.UTC(2026, 4, 11, 11, 0, 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("nextRuns defaults tz to UTC and after to now", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const runs = expr.nextRuns(2);
|
||||
expect(runs.length).toBe(2);
|
||||
});
|
||||
|
||||
it("nextRuns is strictly monotonic", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const runs = expr.nextRuns(5, Date.UTC(2026, 4, 11, 10, 0, 0), "UTC");
|
||||
expect(runs.length).toBe(5);
|
||||
for (let i = 1; i < runs.length; i += 1) {
|
||||
expect(runs[i]).toBeGreaterThan(runs[i - 1] ?? 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("nextRuns rejects non-positive count", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
expect(() => expr.nextRuns(0)).toThrow(CronError);
|
||||
expect(() => expr.nextRuns(-1)).toThrow(CronError);
|
||||
expect(() => expr.nextRuns(1.5)).toThrow(CronError);
|
||||
});
|
||||
|
||||
it("previousRun returns largest match below before", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
const prev = expr.previousRun(Date.UTC(2026, 4, 11, 10, 30, 0), "UTC");
|
||||
expect(prev).toBe(Date.UTC(2026, 4, 11, 10, 0, 0));
|
||||
});
|
||||
|
||||
it("matches returns true on a fire time", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
expect(expr.matches(Date.UTC(2026, 4, 11, 11, 0, 0), "UTC")).toBe(true);
|
||||
expect(expr.matches(Date.UTC(2026, 4, 11, 11, 30, 0), "UTC")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("matches accepts Date input", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 * * * *");
|
||||
expect(
|
||||
expr.matches(new Date(Date.UTC(2026, 4, 11, 11, 0, 0)), "UTC")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches respects seconds field when present", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 9 * * *", { seconds: true });
|
||||
expect(expr.matches(Date.UTC(2026, 4, 11, 9, 0, 0), "UTC")).toBe(true);
|
||||
expect(expr.matches(Date.UTC(2026, 4, 11, 9, 0, 30), "UTC")).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("matches respects year field when present", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 9 1 1 * 2030", {
|
||||
seconds: true,
|
||||
years: true,
|
||||
});
|
||||
expect(expr.matches(Date.UTC(2030, 0, 1, 9, 0, 0), "UTC")).toBe(true);
|
||||
expect(expr.matches(Date.UTC(2026, 0, 1, 9, 0, 0), "UTC")).toBe(false);
|
||||
});
|
||||
|
||||
it("impossible expressions throw NEXT_RUN_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expr = parseCron("0 0 31 2 *");
|
||||
try {
|
||||
expr.nextRun(Date.UTC(2026, 0, 1), "UTC");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.NEXT_RUN_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidCron", () => {
|
||||
it("returns true for parseable input", { timeout: 5000 }, () => {
|
||||
expect(isValidCron("0 * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false (never throws) for garbage", { timeout: 5000 }, () => {
|
||||
expect(isValidCron("not-a-cron-expression")).toBe(false);
|
||||
expect(isValidCron("")).toBe(false);
|
||||
});
|
||||
|
||||
it("honors options", { timeout: 5000 }, () => {
|
||||
expect(isValidCron("0 */5 * * * *", { seconds: true })).toBe(true);
|
||||
expect(isValidCron("0 */5 * * * *")).toBe(false);
|
||||
});
|
||||
});
|
||||
256
packages/foundation/timing/cron/src/parse/parse-cron.ts
Normal file
256
packages/foundation/timing/cron/src/parse/parse-cron.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import { parseField } from "../fields/cron-fields.js";
|
||||
import { expandMacro } from "../macros/macros.js";
|
||||
import { computeNextRun, computePreviousRun } from "../next-run/next-run.js";
|
||||
import { recordParse } from "../otel/cron-otel.js";
|
||||
import { toWallClock } from "../timezone/timezone.js";
|
||||
import { ParseOptionsSchema } from "./parse-cron.schemas.js";
|
||||
import type { CronFields, ParseOptions } from "./parse-cron.types.js";
|
||||
|
||||
const DEFAULT_TZ = "UTC";
|
||||
|
||||
/** Public `CronExpression` returned by `parseCron`. Methods are bound to
|
||||
* the captured field set so destructuring is safe. */
|
||||
export interface CronExpression {
|
||||
readonly expr: string;
|
||||
readonly fields: CronFields;
|
||||
/**
|
||||
* Next epoch instant strictly greater than `after` matching the
|
||||
* expression in `tz`. Throws `CronError(CRON.NEXT_RUN_NOT_FOUND)` when
|
||||
* the 8-year search horizon is exhausted.
|
||||
*/
|
||||
nextRun(after?: number | Date, tz?: string): number;
|
||||
nextRuns(count: number, after?: number | Date, tz?: string): number[];
|
||||
previousRun(before?: number | Date, tz?: string): number;
|
||||
matches(when: number | Date, tz?: string): boolean;
|
||||
}
|
||||
|
||||
function toEpoch(value: number | Date | undefined): number {
|
||||
if (value === undefined) return Date.now();
|
||||
if (value instanceof Date) return value.getTime();
|
||||
return value;
|
||||
}
|
||||
|
||||
function tokenize(
|
||||
expanded: string,
|
||||
options: ParseOptions
|
||||
): { tokens: string[]; useSeconds: boolean; useYears: boolean } {
|
||||
const tokens = expanded.trim().split(/\s+/);
|
||||
const wantSeconds = options.seconds === true;
|
||||
const wantYears = options.years === true;
|
||||
if (tokens.length === 5 && !wantSeconds && !wantYears) {
|
||||
return { tokens, useSeconds: false, useYears: false };
|
||||
}
|
||||
if (tokens.length === 6 && wantSeconds && !wantYears) {
|
||||
return { tokens, useSeconds: true, useYears: false };
|
||||
}
|
||||
if (tokens.length === 7 && wantSeconds && wantYears) {
|
||||
return { tokens, useSeconds: true, useYears: true };
|
||||
}
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{
|
||||
context: {
|
||||
expr: expanded,
|
||||
fieldCount: tokens.length,
|
||||
seconds: wantSeconds,
|
||||
years: wantYears,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildFields(
|
||||
tokens: string[],
|
||||
useSeconds: boolean,
|
||||
useYears: boolean
|
||||
): CronFields {
|
||||
// `tokenize` has already validated `tokens.length` against the
|
||||
// `useSeconds`/`useYears` flags, so the positional reads below are safe.
|
||||
let idx = 0;
|
||||
const next = (): string => tokens[idx++] as string;
|
||||
const seconds = useSeconds ? parseField(next(), "second") : undefined;
|
||||
const minutes = parseField(next(), "minute");
|
||||
const hours = parseField(next(), "hour");
|
||||
const daysOfMonth = parseField(next(), "dayOfMonth");
|
||||
const months = parseField(next(), "month");
|
||||
const daysOfWeek = parseField(next(), "dayOfWeek");
|
||||
const years = useYears ? parseField(next(), "year") : undefined;
|
||||
const fields: CronFields = {
|
||||
...(seconds !== undefined ? { seconds } : {}),
|
||||
minutes,
|
||||
hours,
|
||||
daysOfMonth,
|
||||
months,
|
||||
daysOfWeek,
|
||||
...(years !== undefined ? { years } : {}),
|
||||
};
|
||||
return Object.freeze(fields);
|
||||
}
|
||||
|
||||
function isFullDayOfMonth(values: readonly number[]): boolean {
|
||||
return values.length === 31;
|
||||
}
|
||||
|
||||
function isFullDayOfWeek(values: readonly number[]): boolean {
|
||||
return values.length === 7;
|
||||
}
|
||||
|
||||
function enforceStrictDoW(expr: string, fields: CronFields): void {
|
||||
const domFull = isFullDayOfMonth(fields.daysOfMonth);
|
||||
const dowFull = isFullDayOfWeek(fields.daysOfWeek);
|
||||
if (!domFull && !dowFull) {
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{ context: { expr, reason: "strictDoW: both dom and dow set" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fieldsMatchWall(
|
||||
fields: CronFields,
|
||||
wall: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
weekday: number;
|
||||
}
|
||||
): boolean {
|
||||
if (fields.seconds !== undefined && !fields.seconds.includes(wall.second)) {
|
||||
return false;
|
||||
}
|
||||
if (!fields.minutes.includes(wall.minute)) return false;
|
||||
if (!fields.hours.includes(wall.hour)) return false;
|
||||
if (!fields.months.includes(wall.month)) return false;
|
||||
if (fields.years !== undefined && !fields.years.includes(wall.year)) {
|
||||
return false;
|
||||
}
|
||||
// Vixie OR semantics: when both dom and dow are restricted, EITHER
|
||||
// match is sufficient. When one is `*` (full range), the other must
|
||||
// match.
|
||||
const domFull = isFullDayOfMonth(fields.daysOfMonth);
|
||||
const dowFull = isFullDayOfWeek(fields.daysOfWeek);
|
||||
const domMatches = fields.daysOfMonth.includes(wall.day);
|
||||
const dowMatches = fields.daysOfWeek.includes(wall.weekday);
|
||||
if (domFull && dowFull) return true;
|
||||
if (domFull) return dowMatches;
|
||||
if (dowFull) return domMatches;
|
||||
return domMatches || dowMatches;
|
||||
}
|
||||
|
||||
function buildExpression(expr: string, fields: CronFields): CronExpression {
|
||||
function nextRun(after?: number | Date, tz: string = DEFAULT_TZ): number {
|
||||
return computeNextRun(fields, toEpoch(after), tz);
|
||||
}
|
||||
function previousRun(
|
||||
before?: number | Date,
|
||||
tz: string = DEFAULT_TZ
|
||||
): number {
|
||||
return computePreviousRun(fields, toEpoch(before), tz);
|
||||
}
|
||||
function nextRuns(
|
||||
count: number,
|
||||
after?: number | Date,
|
||||
tz: string = DEFAULT_TZ
|
||||
): number[] {
|
||||
if (!Number.isInteger(count) || count <= 0) {
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{
|
||||
context: {
|
||||
expr,
|
||||
reason: "nextRuns count must be positive integer",
|
||||
count,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const out: number[] = [];
|
||||
let cursor = toEpoch(after);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const next = computeNextRun(fields, cursor, tz);
|
||||
out.push(next);
|
||||
cursor = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function matches(when: number | Date, tz: string = DEFAULT_TZ): boolean {
|
||||
const wall = toWallClock(toEpoch(when), tz);
|
||||
return fieldsMatchWall(fields, wall);
|
||||
}
|
||||
return Object.freeze({
|
||||
expr,
|
||||
fields,
|
||||
nextRun,
|
||||
nextRuns,
|
||||
previousRun,
|
||||
matches,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cron expression into a frozen `CronExpression`. Supports the
|
||||
* standard 5-field form by default; 6-field (with seconds) and 7-field
|
||||
* (Quartz-style year) forms are opt-in via `options`.
|
||||
*/
|
||||
export function parseCron(
|
||||
expr: string,
|
||||
options: ParseOptions = {}
|
||||
): CronExpression {
|
||||
const parsed = ParseOptionsSchema.parse(options);
|
||||
const validatedOptions: ParseOptions = {
|
||||
...(parsed.seconds !== undefined ? { seconds: parsed.seconds } : {}),
|
||||
...(parsed.years !== undefined ? { years: parsed.years } : {}),
|
||||
...(parsed.strictDoW !== undefined
|
||||
? { strictDoW: parsed.strictDoW }
|
||||
: {}),
|
||||
};
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
if (typeof expr !== "string" || expr.length === 0) {
|
||||
throw new CronError(
|
||||
CRON_CODES.INVALID_EXPRESSION,
|
||||
"cron.invalid_expression",
|
||||
{ context: { expr, reason: "empty input" } }
|
||||
);
|
||||
}
|
||||
const expansion = expandMacro(expr);
|
||||
const canonical = expansion ?? expr;
|
||||
const { tokens, useSeconds, useYears } = tokenize(
|
||||
canonical,
|
||||
validatedOptions
|
||||
);
|
||||
const fields = buildFields(tokens, useSeconds, useYears);
|
||||
if (validatedOptions.strictDoW === true) {
|
||||
enforceStrictDoW(expr, fields);
|
||||
}
|
||||
const built = buildExpression(expr, fields);
|
||||
recordParse(Date.now() - t0, "success");
|
||||
return built;
|
||||
} catch (err) {
|
||||
recordParse(Date.now() - t0, "error");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-throwing variant of `parseCron`. Returns `true` when the expression
|
||||
* is parseable under `options`, `false` otherwise. Catches every thrown
|
||||
* value to honor the spec contract that this helper never throws.
|
||||
*/
|
||||
export function isValidCron(expr: string, options: ParseOptions = {}): boolean {
|
||||
try {
|
||||
parseCron(expr, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export { fieldsMatchWall };
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Parsed cron field set. Optional fields are present only when the
|
||||
* corresponding `ParseOptions` flag is enabled. */
|
||||
export interface CronFields {
|
||||
readonly seconds?: readonly number[];
|
||||
readonly minutes: readonly number[];
|
||||
readonly hours: readonly number[];
|
||||
readonly daysOfMonth: readonly number[];
|
||||
readonly months: readonly number[];
|
||||
readonly daysOfWeek: readonly number[];
|
||||
readonly years?: readonly number[];
|
||||
}
|
||||
|
||||
/** Options controlling field count and Vixie OR semantics. */
|
||||
export interface ParseOptions {
|
||||
/** Accept 6-field expressions (seconds + 5 std). Default: false. */
|
||||
seconds?: boolean;
|
||||
/** Accept 7-field Quartz year field. Default: false. */
|
||||
years?: boolean;
|
||||
/** Reject dayOfMonth + dayOfWeek both being non-wildcards. Default: false. */
|
||||
strictDoW?: boolean;
|
||||
}
|
||||
|
||||
/** Discriminator returned by the orchestrator to drive next-run logic. */
|
||||
export interface CronExpressionShape {
|
||||
readonly expr: string;
|
||||
readonly fields: CronFields;
|
||||
}
|
||||
109
packages/foundation/timing/cron/src/scheduler/cron-scheduler.md
Normal file
109
packages/foundation/timing/cron/src/scheduler/cron-scheduler.md
Normal 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.
|
||||
@@ -0,0 +1,18 @@
|
||||
import z from "zod";
|
||||
|
||||
export const CronSchedulerOptionsSchema = z.object({
|
||||
clock: z.function().optional(),
|
||||
onError: z.function().optional(),
|
||||
timers: z
|
||||
.object({
|
||||
setTimer: z.function(),
|
||||
clearTimer: z.function(),
|
||||
})
|
||||
.optional(),
|
||||
generateId: z.function().optional(),
|
||||
});
|
||||
|
||||
export const CronSchedulerAddOptionsSchema = z.object({
|
||||
tz: z.string().min(1).optional(),
|
||||
id: z.string().min(1).max(128).optional(),
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CronScheduler, createCronScheduler } from "./cron-scheduler.js";
|
||||
import type { SchedulerTimers } from "./cron-scheduler.types.js";
|
||||
|
||||
interface PendingTimer {
|
||||
id: number;
|
||||
cb: () => void;
|
||||
fireAt: number;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
now: number;
|
||||
pending: Map<number, PendingTimer>;
|
||||
timers: SchedulerTimers;
|
||||
advance: (delta: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function makeHarness(start = Date.UTC(2026, 4, 11, 9, 59, 0)): Harness {
|
||||
const state = { now: start, nextId: 1 };
|
||||
const pending = new Map<number, PendingTimer>();
|
||||
const timers: SchedulerTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = state.nextId++;
|
||||
pending.set(id, { id, cb, fireAt: state.now + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => pending.delete(id),
|
||||
};
|
||||
async function advance(delta: number): Promise<void> {
|
||||
const target = state.now + delta;
|
||||
while (true) {
|
||||
let earliest: PendingTimer | null = null;
|
||||
for (const t of pending.values()) {
|
||||
if (t.fireAt > target) continue;
|
||||
if (earliest === null || t.fireAt < earliest.fireAt)
|
||||
earliest = t;
|
||||
}
|
||||
if (earliest === null) break;
|
||||
state.now = earliest.fireAt;
|
||||
pending.delete(earliest.id);
|
||||
earliest.cb();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
state.now = target;
|
||||
}
|
||||
return {
|
||||
get now() {
|
||||
return state.now;
|
||||
},
|
||||
set now(v: number) {
|
||||
state.now = v;
|
||||
},
|
||||
pending,
|
||||
timers,
|
||||
advance,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CronScheduler", () => {
|
||||
it("constructs with defaults", { timeout: 5000 }, () => {
|
||||
const scheduler = new CronScheduler();
|
||||
expect(scheduler.list().length).toBe(0);
|
||||
});
|
||||
|
||||
it("createCronScheduler factory mirror", { timeout: 5000 }, () => {
|
||||
const scheduler = createCronScheduler();
|
||||
expect(scheduler).toBeInstanceOf(CronScheduler);
|
||||
});
|
||||
|
||||
it("add returns generated id when omitted", { timeout: 5000 }, () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
const id = scheduler.add("0 * * * *", () => undefined);
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("add honors caller-supplied id", { timeout: 5000 }, () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
const id = scheduler.add("0 * * * *", () => undefined, {
|
||||
id: "my-job",
|
||||
});
|
||||
expect(id).toBe("my-job");
|
||||
});
|
||||
|
||||
it("remove cancels timers and returns true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
const id = scheduler.add("0 * * * *", () => undefined);
|
||||
scheduler.start();
|
||||
expect(scheduler.remove(id)).toBe(true);
|
||||
expect(scheduler.remove("unknown")).toBe(false);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("list snapshots every job with its next run", { timeout: 5000 }, () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", () => undefined, { id: "hourly" });
|
||||
scheduler.add("*/15 * * * *", () => undefined, { id: "quarter" });
|
||||
const snap = scheduler.list();
|
||||
expect(snap.length).toBe(2);
|
||||
expect(snap.every((j) => j.nextRun !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it("fires registered job when timer elapses", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const harness = makeHarness();
|
||||
let fires = 0;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", () => {
|
||||
fires += 1;
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
expect(fires).toBe(1);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("re-schedules across successive fires", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
let fires = 0;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", () => {
|
||||
fires += 1;
|
||||
});
|
||||
scheduler.start();
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await harness.advance(60 * 60 * 1000);
|
||||
}
|
||||
expect(fires).toBeGreaterThanOrEqual(2);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("routes handler errors to onError sink", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
const errors: unknown[] = [];
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
onError: (err) => errors.push(err),
|
||||
});
|
||||
scheduler.add("0 * * * *", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
expect(errors.length).toBe(1);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("swallows handler errors when no sink", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", () => {
|
||||
throw new Error("ignored");
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("start is idempotent", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", () => undefined);
|
||||
scheduler.start();
|
||||
scheduler.start();
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("stop is idempotent and a no-op when not started", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const scheduler = new CronScheduler();
|
||||
await scheduler.stop();
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("stop awaits in-flight handlers", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
let resolveHandler!: () => void;
|
||||
const handlerSettled = new Promise<void>((r) => {
|
||||
resolveHandler = r;
|
||||
});
|
||||
let completed = false;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", async () => {
|
||||
await handlerSettled;
|
||||
completed = true;
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
const stopPromise = scheduler.stop();
|
||||
resolveHandler();
|
||||
await stopPromise;
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it("add after start schedules immediately", { timeout: 5000 }, async () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.start();
|
||||
let fires = 0;
|
||||
scheduler.add("0 * * * *", () => {
|
||||
fires += 1;
|
||||
});
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
expect(fires).toBe(1);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("uses real timers when not injected", { timeout: 5000 }, async () => {
|
||||
const scheduler = new CronScheduler();
|
||||
scheduler.add("0 * * * *", () => undefined);
|
||||
scheduler.start();
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("list falls back to null when nextRun throws", { timeout: 5000 }, () => {
|
||||
const harness = makeHarness();
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
});
|
||||
// 0 0 31 2 * has no fire time within the horizon → nextRun throws.
|
||||
scheduler.add("0 0 31 2 *", () => undefined);
|
||||
const snap = scheduler.list();
|
||||
expect(snap[0]?.nextRun).toBeNull();
|
||||
});
|
||||
|
||||
it("custom generateId is honored", { timeout: 5000 }, () => {
|
||||
const harness = makeHarness();
|
||||
let counter = 0;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
generateId: () => `cron-${++counter}`,
|
||||
});
|
||||
const id = scheduler.add("0 * * * *", () => undefined);
|
||||
expect(id).toBe("cron-1");
|
||||
});
|
||||
|
||||
it("routes nextRun errors during scheduling to onError sink", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const harness = makeHarness();
|
||||
const errors: unknown[] = [];
|
||||
const scheduler = new CronScheduler({
|
||||
clock: () => harness.now,
|
||||
timers: harness.timers,
|
||||
onError: (err, id) => errors.push({ err, id }),
|
||||
});
|
||||
scheduler.add("0 0 31 2 *", () => undefined, { id: "impossible" });
|
||||
scheduler.start();
|
||||
// nextRun throws; no timer should be scheduled.
|
||||
expect(harness.pending.size).toBe(0);
|
||||
expect(errors.length).toBe(1);
|
||||
await scheduler.stop();
|
||||
});
|
||||
});
|
||||
169
packages/foundation/timing/cron/src/scheduler/cron-scheduler.ts
Normal file
169
packages/foundation/timing/cron/src/scheduler/cron-scheduler.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { type CronExpression, parseCron } from "../parse/parse-cron.js";
|
||||
import type {
|
||||
CronSchedulerAddOptions,
|
||||
CronSchedulerJob,
|
||||
CronSchedulerOptions,
|
||||
SchedulerTimers,
|
||||
} from "./cron-scheduler.types.js";
|
||||
|
||||
interface ScheduledJob {
|
||||
id: string;
|
||||
expr: string;
|
||||
tz: string;
|
||||
expression: CronExpression;
|
||||
handler: () => void | Promise<void>;
|
||||
timerId: number | null;
|
||||
}
|
||||
|
||||
function defaultTimers(): SchedulerTimers {
|
||||
return {
|
||||
setTimer: (cb, ms) =>
|
||||
globalThis.setTimeout(cb, ms) as unknown as number,
|
||||
clearTimer: (id) => globalThis.clearTimeout(id),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultGenerateId(): string {
|
||||
// `crypto.randomUUID()` is universally available in Node ≥18 and every
|
||||
// modern browser. Callers in environments lacking it MUST inject a
|
||||
// custom `generateId` via `CronSchedulerOptions`.
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
const DEFAULT_TZ = "UTC";
|
||||
|
||||
/** Lightweight in-process cron scheduler. Drives handlers from a single
|
||||
* per-job timer using the injectable `Clock` and timer pair so tests
|
||||
* can fast-forward time deterministically. */
|
||||
export class CronScheduler {
|
||||
private readonly clock: Clock;
|
||||
private readonly onError: ((err: unknown, id: string) => void) | undefined;
|
||||
private readonly timers: SchedulerTimers;
|
||||
private readonly generateId: () => string;
|
||||
private readonly jobs = new Map<string, ScheduledJob>();
|
||||
private readonly inFlight = new Set<Promise<void>>();
|
||||
private started = false;
|
||||
private stopping = false;
|
||||
|
||||
public constructor(options: CronSchedulerOptions = {}) {
|
||||
this.clock = options.clock ?? ((): number => Date.now());
|
||||
this.onError = options.onError;
|
||||
this.timers = options.timers ?? defaultTimers();
|
||||
this.generateId = options.generateId ?? defaultGenerateId;
|
||||
}
|
||||
|
||||
/** Register a cron job. Returns the assigned id. */
|
||||
public add(
|
||||
expr: string,
|
||||
handler: () => void | Promise<void>,
|
||||
options: CronSchedulerAddOptions = {}
|
||||
): string {
|
||||
const expression = parseCron(expr);
|
||||
const id = options.id ?? this.generateId();
|
||||
const tz = options.tz ?? DEFAULT_TZ;
|
||||
const job: ScheduledJob = {
|
||||
id,
|
||||
expr,
|
||||
tz,
|
||||
expression,
|
||||
handler,
|
||||
timerId: null,
|
||||
};
|
||||
this.jobs.set(id, job);
|
||||
if (this.started && !this.stopping) this.scheduleNext(job);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Cancel and remove a job by id. */
|
||||
public remove(id: string): boolean {
|
||||
const job = this.jobs.get(id);
|
||||
if (job === undefined) return false;
|
||||
this.cancelTimer(job);
|
||||
this.jobs.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Snapshot of currently-registered jobs and their next-run times. */
|
||||
public list(): readonly CronSchedulerJob[] {
|
||||
const now = this.clock();
|
||||
const out: CronSchedulerJob[] = [];
|
||||
for (const job of this.jobs.values()) {
|
||||
let nextRun: number | null;
|
||||
try {
|
||||
nextRun = job.expression.nextRun(now, job.tz);
|
||||
} catch {
|
||||
nextRun = null;
|
||||
}
|
||||
out.push({ id: job.id, expr: job.expr, nextRun });
|
||||
}
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
/** Idempotent start: schedules every registered job. */
|
||||
public start(): void {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.stopping = false;
|
||||
for (const job of this.jobs.values()) this.scheduleNext(job);
|
||||
}
|
||||
|
||||
/** Idempotent stop: clears every pending timer and awaits in-flight
|
||||
* handlers before resolving. */
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.started) return;
|
||||
this.stopping = true;
|
||||
for (const job of this.jobs.values()) this.cancelTimer(job);
|
||||
await Promise.all(Array.from(this.inFlight));
|
||||
this.started = false;
|
||||
this.stopping = false;
|
||||
}
|
||||
|
||||
private cancelTimer(job: ScheduledJob): void {
|
||||
if (job.timerId !== null) {
|
||||
this.timers.clearTimer(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNext(job: ScheduledJob): void {
|
||||
const now = this.clock();
|
||||
let next: number;
|
||||
try {
|
||||
next = job.expression.nextRun(now, job.tz);
|
||||
} catch (err) {
|
||||
this.reportError(err, job.id);
|
||||
return;
|
||||
}
|
||||
const delay = Math.max(0, next - now);
|
||||
job.timerId = this.timers.setTimer(() => this.fire(job), delay);
|
||||
}
|
||||
|
||||
private fire(job: ScheduledJob): void {
|
||||
job.timerId = null;
|
||||
const promise = this.invokeHandler(job).finally(() => {
|
||||
this.inFlight.delete(promise);
|
||||
if (this.started && !this.stopping) this.scheduleNext(job);
|
||||
});
|
||||
this.inFlight.add(promise);
|
||||
}
|
||||
|
||||
private async invokeHandler(job: ScheduledJob): Promise<void> {
|
||||
try {
|
||||
await job.handler();
|
||||
} catch (err) {
|
||||
this.reportError(err, job.id);
|
||||
}
|
||||
}
|
||||
|
||||
private reportError(err: unknown, id: string): void {
|
||||
if (this.onError !== undefined) this.onError(err, id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience factory mirroring the genesis primitive convention. */
|
||||
export function createCronScheduler(
|
||||
options?: CronSchedulerOptions
|
||||
): CronScheduler {
|
||||
return new CronScheduler(options);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Clock, TimersLike } from "@suaveplan/types";
|
||||
|
||||
/** Pluggable timer pair. Defaults to `globalThis.setTimeout`/`clearTimeout`.
|
||||
* Tests can inject a fake-clock-backed pair to drive the scheduler
|
||||
* deterministically. Aliased to the cross-package `TimersLike` contract
|
||||
* from `@suaveplan/types` so a single `FakeClock`-backed pair drops into
|
||||
* any package that needs scheduling. */
|
||||
export type SchedulerTimers = TimersLike;
|
||||
|
||||
/** Options for `CronScheduler`. */
|
||||
export interface CronSchedulerOptions {
|
||||
/** Wall-clock source. Defaults to `Date.now`. */
|
||||
clock?: Clock;
|
||||
/** Handler-error sink. Errors swallowed when omitted. */
|
||||
onError?: (err: unknown, id: string) => void;
|
||||
/** Injectable timer pair for deterministic tests. */
|
||||
timers?: SchedulerTimers;
|
||||
/** Optional id generator. Defaults to `crypto.randomUUID()`. */
|
||||
generateId?: () => string;
|
||||
}
|
||||
|
||||
/** Per-job descriptor returned by `list()`. */
|
||||
export interface CronSchedulerJob {
|
||||
readonly id: string;
|
||||
readonly expr: string;
|
||||
readonly nextRun: number | null;
|
||||
}
|
||||
|
||||
/** Add-job options. */
|
||||
export interface CronSchedulerAddOptions {
|
||||
/** IANA timezone. Defaults to `UTC`. */
|
||||
tz?: string;
|
||||
/** Caller-supplied id. Auto-generated when omitted. */
|
||||
id?: string;
|
||||
}
|
||||
11
packages/foundation/timing/cron/src/scheduler/index.ts
Normal file
11
packages/foundation/timing/cron/src/scheduler/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { CronScheduler, createCronScheduler } from "./cron-scheduler.js";
|
||||
export {
|
||||
CronSchedulerAddOptionsSchema,
|
||||
CronSchedulerOptionsSchema,
|
||||
} from "./cron-scheduler.schemas.js";
|
||||
export type {
|
||||
CronSchedulerAddOptions,
|
||||
CronSchedulerJob,
|
||||
CronSchedulerOptions,
|
||||
SchedulerTimers,
|
||||
} from "./cron-scheduler.types.js";
|
||||
7
packages/foundation/timing/cron/src/timezone/index.ts
Normal file
7
packages/foundation/timing/cron/src/timezone/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type { WallClock } from "./timezone.js";
|
||||
export {
|
||||
assertTimezone,
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
} from "./timezone.js";
|
||||
88
packages/foundation/timing/cron/src/timezone/timezone.md
Normal file
88
packages/foundation/timing/cron/src/timezone/timezone.md
Normal 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.
|
||||
146
packages/foundation/timing/cron/src/timezone/timezone.test.ts
Normal file
146
packages/foundation/timing/cron/src/timezone/timezone.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
import {
|
||||
assertTimezone,
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
} from "./timezone.js";
|
||||
|
||||
describe("toWallClock", () => {
|
||||
it("decomposes UTC epoch to UTC components", { timeout: 5000 }, () => {
|
||||
const wall = toWallClock(Date.UTC(2026, 4, 11, 10, 30, 15), "UTC");
|
||||
expect(wall.year).toBe(2026);
|
||||
expect(wall.month).toBe(5);
|
||||
expect(wall.day).toBe(11);
|
||||
expect(wall.hour).toBe(10);
|
||||
expect(wall.minute).toBe(30);
|
||||
expect(wall.second).toBe(15);
|
||||
});
|
||||
|
||||
it("applies America/New_York offset", { timeout: 5000 }, () => {
|
||||
const wall = toWallClock(
|
||||
Date.UTC(2026, 0, 1, 5, 0, 0),
|
||||
"America/New_York"
|
||||
);
|
||||
expect(wall.year).toBe(2026);
|
||||
expect(wall.month).toBe(1);
|
||||
expect(wall.day).toBe(1);
|
||||
expect(wall.hour).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0..6 weekday", { timeout: 5000 }, () => {
|
||||
// 2026-05-11 is a Monday.
|
||||
const wall = toWallClock(Date.UTC(2026, 4, 11, 12, 0, 0), "UTC");
|
||||
expect(wall.weekday).toBe(1);
|
||||
});
|
||||
|
||||
it("throws TIMEZONE_INVALID for bad IANA name", { timeout: 5000 }, () => {
|
||||
try {
|
||||
toWallClock(0, "Not/A_Zone");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CronError);
|
||||
expect((err as CronError).code).toBe(CRON_CODES.TIMEZONE_INVALID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromWallClock", () => {
|
||||
it("round-trips UTC components", { timeout: 5000 }, () => {
|
||||
const wall = {
|
||||
year: 2026,
|
||||
month: 5,
|
||||
day: 11,
|
||||
hour: 10,
|
||||
minute: 30,
|
||||
second: 0,
|
||||
};
|
||||
const epoch = fromWallClock(wall, "UTC");
|
||||
expect(epoch).toBe(Date.UTC(2026, 4, 11, 10, 30, 0));
|
||||
});
|
||||
|
||||
it("round-trips America/New_York components (winter)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wall = {
|
||||
year: 2026,
|
||||
month: 1,
|
||||
day: 1,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
};
|
||||
const epoch = fromWallClock(wall, "America/New_York");
|
||||
// NY in January = UTC-5.
|
||||
expect(epoch).toBe(Date.UTC(2026, 0, 1, 5, 0, 0));
|
||||
});
|
||||
|
||||
it("round-trips America/New_York components (summer)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wall = {
|
||||
year: 2026,
|
||||
month: 7,
|
||||
day: 1,
|
||||
hour: 12,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
};
|
||||
const epoch = fromWallClock(wall, "America/New_York");
|
||||
// NY in July = UTC-4.
|
||||
expect(epoch).toBe(Date.UTC(2026, 6, 1, 16, 0, 0));
|
||||
});
|
||||
|
||||
it("converges across the spring-forward gap", { timeout: 5000 }, () => {
|
||||
// 2026-03-08 02:30 local does not exist in America/New_York —
|
||||
// clock jumps from 02:00 to 03:00. The function returns _some_
|
||||
// real instant; callers re-verify the resulting wall clock.
|
||||
const wall = {
|
||||
year: 2026,
|
||||
month: 3,
|
||||
day: 8,
|
||||
hour: 2,
|
||||
minute: 30,
|
||||
second: 0,
|
||||
};
|
||||
const epoch = fromWallClock(wall, "America/New_York");
|
||||
expect(Number.isFinite(epoch)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectDstTransition", () => {
|
||||
it("detects spring-forward in America/New_York", { timeout: 5000 }, () => {
|
||||
const before = Date.UTC(2026, 2, 8, 6, 0, 0); // 01:00 local EST
|
||||
const after = Date.UTC(2026, 2, 8, 8, 0, 0); // 04:00 local EDT
|
||||
expect(detectDstTransition(before, after, "America/New_York")).toBe(
|
||||
"spring-forward"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects fall-back in America/New_York", { timeout: 5000 }, () => {
|
||||
const before = Date.UTC(2026, 10, 1, 5, 0, 0); // 01:00 EDT
|
||||
const after = Date.UTC(2026, 10, 1, 7, 0, 0); // 02:00 EST
|
||||
expect(detectDstTransition(before, after, "America/New_York")).toBe(
|
||||
"fall-back"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no transition crossed", { timeout: 5000 }, () => {
|
||||
const a = Date.UTC(2026, 4, 11, 10, 0, 0);
|
||||
const b = Date.UTC(2026, 4, 11, 11, 0, 0);
|
||||
expect(detectDstTransition(a, b, "America/New_York")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertTimezone", () => {
|
||||
it("accepts known zones", { timeout: 5000 }, () => {
|
||||
expect(() => assertTimezone("UTC")).not.toThrow();
|
||||
expect(() => assertTimezone("Europe/London")).not.toThrow();
|
||||
expect(() => assertTimezone("Australia/Sydney")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown zones", { timeout: 5000 }, () => {
|
||||
expect(() => assertTimezone("Mars/Olympus_Mons")).toThrow(CronError);
|
||||
});
|
||||
});
|
||||
171
packages/foundation/timing/cron/src/timezone/timezone.ts
Normal file
171
packages/foundation/timing/cron/src/timezone/timezone.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { CRON_CODES, CronError } from "../errors/cron-error.js";
|
||||
|
||||
/** Wall-clock components in a specific IANA timezone. */
|
||||
export interface WallClock {
|
||||
readonly year: number;
|
||||
readonly month: number;
|
||||
readonly day: number;
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
readonly second: number;
|
||||
/** 0 = Sunday … 6 = Saturday — matches cron day-of-week numbering. */
|
||||
readonly weekday: number;
|
||||
}
|
||||
|
||||
const TZ_FORMAT_CACHE = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function getFormatter(tz: string): Intl.DateTimeFormat {
|
||||
const cached = TZ_FORMAT_CACHE.get(tz);
|
||||
if (cached !== undefined) return cached;
|
||||
let fmt: Intl.DateTimeFormat;
|
||||
try {
|
||||
fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
weekday: "short",
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new CronError(
|
||||
CRON_CODES.TIMEZONE_INVALID,
|
||||
"cron.timezone_invalid",
|
||||
{ context: { tz }, cause: cause as Error }
|
||||
);
|
||||
}
|
||||
TZ_FORMAT_CACHE.set(tz, fmt);
|
||||
return fmt;
|
||||
}
|
||||
|
||||
const WEEKDAY_MAP: Readonly<Record<string, number>> = Object.freeze({
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert an epoch millisecond timestamp to its wall-clock components in
|
||||
* `tz`. Throws `CronError(CRON.TIMEZONE_INVALID)` when the IANA name is
|
||||
* rejected by the platform `Intl.DateTimeFormat`.
|
||||
*/
|
||||
export function toWallClock(epochMs: number, tz: string): WallClock {
|
||||
const fmt = getFormatter(tz);
|
||||
const parts = fmt.formatToParts(new Date(epochMs));
|
||||
let year = 0;
|
||||
let month = 0;
|
||||
let day = 0;
|
||||
let hour = 0;
|
||||
let minute = 0;
|
||||
let second = 0;
|
||||
let weekday = 0;
|
||||
for (const part of parts) {
|
||||
switch (part.type) {
|
||||
case "year":
|
||||
year = Number.parseInt(part.value, 10);
|
||||
break;
|
||||
case "month":
|
||||
month = Number.parseInt(part.value, 10);
|
||||
break;
|
||||
case "day":
|
||||
day = Number.parseInt(part.value, 10);
|
||||
break;
|
||||
case "hour":
|
||||
// Intl emits "24" for midnight at the tail end of some hourCycle
|
||||
// boundaries; normalize to 0.
|
||||
hour = Number.parseInt(part.value, 10) % 24;
|
||||
break;
|
||||
case "minute":
|
||||
minute = Number.parseInt(part.value, 10);
|
||||
break;
|
||||
case "second":
|
||||
second = Number.parseInt(part.value, 10);
|
||||
break;
|
||||
case "weekday":
|
||||
// Intl always emits one of Sun..Sat for the `short` weekday
|
||||
// formatter we configured.
|
||||
weekday = WEEKDAY_MAP[part.value] as number;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { year, month, day, hour, minute, second, weekday };
|
||||
}
|
||||
|
||||
function utcEpoch(components: Omit<WallClock, "weekday">): number {
|
||||
return Date.UTC(
|
||||
components.year,
|
||||
components.month - 1,
|
||||
components.day,
|
||||
components.hour,
|
||||
components.minute,
|
||||
components.second
|
||||
);
|
||||
}
|
||||
|
||||
function offsetMs(epochMs: number, tz: string): number {
|
||||
const wall = toWallClock(epochMs, tz);
|
||||
return utcEpoch(wall) - epochMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the epoch instant whose wall-clock representation in `tz` matches
|
||||
* `components`. Iteratively corrects for the timezone offset (two passes
|
||||
* suffice for every IANA zone). When the requested wall-clock falls inside
|
||||
* a DST spring-forward gap, returns the first existing instant strictly
|
||||
* after the requested wall-clock — callers should re-verify the result
|
||||
* matches their cron field set.
|
||||
*/
|
||||
export function fromWallClock(
|
||||
components: Omit<WallClock, "weekday">,
|
||||
tz: string
|
||||
): number {
|
||||
const targetUtc = utcEpoch(components);
|
||||
// Fixed-point iteration: target's NY-wall-clock at instant `e`
|
||||
// is exactly `target` when `e = targetUtc - offsetMs(e, tz)`.
|
||||
// Start by guessing the instant's offset using `targetUtc` itself.
|
||||
let candidate = targetUtc - offsetMs(targetUtc, tz);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const refined = targetUtc - offsetMs(candidate, tz);
|
||||
if (refined === candidate) return candidate;
|
||||
candidate = refined;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a DST transition between two adjacent epoch instants in `tz`. The
|
||||
* return value indicates the kind of transition; `null` means no transition
|
||||
* occurred.
|
||||
*/
|
||||
export function detectDstTransition(
|
||||
beforeEpochMs: number,
|
||||
afterEpochMs: number,
|
||||
tz: string
|
||||
): "spring-forward" | "fall-back" | null {
|
||||
const beforeOffset = offsetMs(beforeEpochMs, tz);
|
||||
const afterOffset = offsetMs(afterEpochMs, tz);
|
||||
if (beforeOffset === afterOffset) return null;
|
||||
// In a spring-forward, local time jumps ahead — offset becomes more
|
||||
// negative (e.g. EST -5h → EDT -4h means utcEpoch increases relative to
|
||||
// real epoch, so offset increases). In fall-back, offset decreases.
|
||||
return afterOffset > beforeOffset ? "spring-forward" : "fall-back";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an IANA timezone name eagerly. Useful at the API boundary so
|
||||
* configuration errors fail before any next-run computation.
|
||||
*
|
||||
* @throws CronError(CRON.TIMEZONE_INVALID)
|
||||
*/
|
||||
export function assertTimezone(tz: string): void {
|
||||
getFormatter(tz);
|
||||
}
|
||||
128
packages/foundation/timing/cron/tests/integration.test.ts
Normal file
128
packages/foundation/timing/cron/tests/integration.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CronScheduler } from "../src/index.js";
|
||||
import type { SchedulerTimers } from "../src/scheduler/cron-scheduler.types.js";
|
||||
|
||||
interface PendingTimer {
|
||||
id: number;
|
||||
cb: () => void;
|
||||
fireAt: number;
|
||||
}
|
||||
|
||||
function createHarness(start: number) {
|
||||
const state = { now: start, nextId: 1 };
|
||||
const pending = new Map<number, PendingTimer>();
|
||||
const timers: SchedulerTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = state.nextId++;
|
||||
pending.set(id, { id, cb, fireAt: state.now + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => pending.delete(id),
|
||||
};
|
||||
async function advance(delta: number): Promise<void> {
|
||||
const target = state.now + delta;
|
||||
while (true) {
|
||||
let earliest: PendingTimer | null = null;
|
||||
for (const t of pending.values()) {
|
||||
if (t.fireAt > target) continue;
|
||||
if (earliest === null || t.fireAt < earliest.fireAt)
|
||||
earliest = t;
|
||||
}
|
||||
if (earliest === null) break;
|
||||
state.now = earliest.fireAt;
|
||||
pending.delete(earliest.id);
|
||||
earliest.cb();
|
||||
// Let micro-task queue drain so the handler's finally re-schedules
|
||||
// before we look for the next due timer.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
state.now = target;
|
||||
}
|
||||
return {
|
||||
clock: () => state.now,
|
||||
timers,
|
||||
advance,
|
||||
get pending() {
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("integration — CronScheduler with multiple jobs", () => {
|
||||
it("fires three jobs at their independent intervals", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const harness = createHarness(Date.UTC(2026, 4, 11, 9, 59, 0));
|
||||
const fires = { hourly: 0, fiveMin: 0, daily: 0 };
|
||||
const scheduler = new CronScheduler({
|
||||
clock: harness.clock,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add(
|
||||
"0 * * * *",
|
||||
() => {
|
||||
fires.hourly += 1;
|
||||
},
|
||||
{ id: "hourly" }
|
||||
);
|
||||
scheduler.add(
|
||||
"*/5 * * * *",
|
||||
() => {
|
||||
fires.fiveMin += 1;
|
||||
},
|
||||
{ id: "five" }
|
||||
);
|
||||
scheduler.add(
|
||||
"0 0 * * *",
|
||||
() => {
|
||||
fires.daily += 1;
|
||||
},
|
||||
{ id: "daily" }
|
||||
);
|
||||
scheduler.start();
|
||||
|
||||
// Advance 2 hours and 30 minutes.
|
||||
await harness.advance(2 * 60 * 60 * 1000 + 30 * 60 * 1000);
|
||||
|
||||
expect(fires.hourly).toBeGreaterThanOrEqual(2);
|
||||
expect(fires.fiveMin).toBeGreaterThanOrEqual(20);
|
||||
await scheduler.stop();
|
||||
});
|
||||
|
||||
it("stop() drains in-flight handler", { timeout: 30000 }, async () => {
|
||||
const harness = createHarness(Date.UTC(2026, 4, 11, 9, 59, 0));
|
||||
let resolveHandler!: () => void;
|
||||
const holding = new Promise<void>((r) => {
|
||||
resolveHandler = r;
|
||||
});
|
||||
let completed = false;
|
||||
const scheduler = new CronScheduler({
|
||||
clock: harness.clock,
|
||||
timers: harness.timers,
|
||||
});
|
||||
scheduler.add("0 * * * *", async () => {
|
||||
await holding;
|
||||
completed = true;
|
||||
});
|
||||
scheduler.start();
|
||||
await harness.advance(2 * 60 * 1000);
|
||||
|
||||
const stopPromise = scheduler.stop();
|
||||
let stopResolved = false;
|
||||
const watcher = stopPromise.then(() => {
|
||||
stopResolved = true;
|
||||
});
|
||||
|
||||
// Give the runtime a few microtasks to let the watcher attach.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(stopResolved).toBe(false);
|
||||
|
||||
resolveHandler();
|
||||
await watcher;
|
||||
expect(completed).toBe(true);
|
||||
expect(stopResolved).toBe(true);
|
||||
});
|
||||
});
|
||||
111
packages/foundation/timing/cron/tests/property.test.ts
Normal file
111
packages/foundation/timing/cron/tests/property.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { parseCron } from "../src/index.js";
|
||||
|
||||
const EXPRESSIONS = [
|
||||
"* * * * *",
|
||||
"0 * * * *",
|
||||
"*/5 * * * *",
|
||||
"*/15 * * * *",
|
||||
"0 9 * * *",
|
||||
"0 9 * * MON-FRI",
|
||||
"30 14 * * 0",
|
||||
"0 0 1 * *",
|
||||
"0 0 1 1 *",
|
||||
"0,15,30,45 9-17 * * MON-FRI",
|
||||
"0 10-12/2 * * *",
|
||||
];
|
||||
|
||||
const TIMEZONES = [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"Europe/London",
|
||||
"Australia/Sydney",
|
||||
];
|
||||
|
||||
const timestampArb = fc.integer({
|
||||
min: Date.UTC(2026, 0, 1),
|
||||
max: Date.UTC(2028, 0, 1),
|
||||
});
|
||||
|
||||
describe("property — nextRun > after", () => {
|
||||
it("returns a strictly later instant for every supported expression", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const next = parseCron(e).nextRun(t, tz);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next as number).toBeGreaterThan(t);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — nextRuns strictly monotonic", () => {
|
||||
it("every adjacent pair is strictly ascending", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const runs = parseCron(e).nextRuns(10, t, tz);
|
||||
expect(runs.length).toBeGreaterThan(0);
|
||||
for (let i = 1; i < runs.length; i += 1) {
|
||||
expect(runs[i]).toBeGreaterThan(runs[i - 1] ?? 0);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — matches(nextRun) is true", () => {
|
||||
it("the returned instant always matches the expression", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const expr = parseCron(e);
|
||||
const next = expr.nextRun(t, tz);
|
||||
expect(expr.matches(next as number, tz)).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property — previousRun(nextRun) <= after", () => {
|
||||
it("backward search lands at-or-before the original anchor", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...EXPRESSIONS),
|
||||
timestampArb,
|
||||
fc.constantFrom(...TIMEZONES),
|
||||
(e, t, tz) => {
|
||||
const expr = parseCron(e);
|
||||
const next = expr.nextRun(t, tz) as number;
|
||||
const prev = expr.previousRun(next, tz);
|
||||
expect(prev).not.toBeNull();
|
||||
expect(prev as number).toBeLessThanOrEqual(t);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
1
packages/foundation/timing/cron/tests/setup/polyfills.ts
Normal file
1
packages/foundation/timing/cron/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
251
packages/foundation/timing/cron/tests/vectors.test.ts
Normal file
251
packages/foundation/timing/cron/tests/vectors.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseCron } from "../src/index.js";
|
||||
|
||||
interface Vector {
|
||||
expr: string;
|
||||
after: number;
|
||||
tz: string;
|
||||
expected: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const utc = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number =>
|
||||
Date.UTC(y, mo, d, h, mi, s);
|
||||
|
||||
const vectors: Vector[] = [
|
||||
// Hourly variants
|
||||
{
|
||||
expr: "0 * * * *",
|
||||
after: utc(2026, 4, 11, 10, 30),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 11, 0),
|
||||
label: "hourly @00",
|
||||
},
|
||||
{
|
||||
expr: "*/15 * * * *",
|
||||
after: utc(2026, 4, 11, 10, 7),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 10, 15),
|
||||
label: "every 15 minutes",
|
||||
},
|
||||
{
|
||||
expr: "*/15 * * * *",
|
||||
after: utc(2026, 4, 11, 10, 45),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 11, 0),
|
||||
label: "every 15 wrap",
|
||||
},
|
||||
{
|
||||
expr: "*/30 * * * *",
|
||||
after: utc(2026, 4, 11, 10, 29),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 10, 30),
|
||||
label: "every 30 minutes",
|
||||
},
|
||||
|
||||
// Daily
|
||||
{
|
||||
expr: "0 9 * * *",
|
||||
after: utc(2026, 4, 11, 8, 0),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 9, 0),
|
||||
label: "daily 9am",
|
||||
},
|
||||
{
|
||||
expr: "0 0 * * *",
|
||||
after: utc(2026, 4, 11, 12, 0),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 12, 0, 0),
|
||||
label: "daily midnight",
|
||||
},
|
||||
{
|
||||
expr: "@daily",
|
||||
after: utc(2026, 4, 11, 12, 0),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 12, 0, 0),
|
||||
label: "@daily macro",
|
||||
},
|
||||
{
|
||||
expr: "@hourly",
|
||||
after: utc(2026, 4, 11, 10, 30),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 11, 0),
|
||||
label: "@hourly macro",
|
||||
},
|
||||
|
||||
// Weekdays / weekends
|
||||
// 2026-05-09 is Saturday. Next 9am weekday = Mon 2026-05-11.
|
||||
{
|
||||
expr: "0 9 * * MON-FRI",
|
||||
after: utc(2026, 4, 9),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 9, 0),
|
||||
label: "9am weekdays",
|
||||
},
|
||||
{
|
||||
expr: "0 12 * * 0",
|
||||
after: utc(2026, 4, 11),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 17, 12, 0),
|
||||
label: "noon Sundays",
|
||||
},
|
||||
{
|
||||
expr: "0 9 * * 7",
|
||||
after: utc(2026, 4, 11),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 17, 9, 0),
|
||||
label: "9am Sundays via 7",
|
||||
},
|
||||
|
||||
// Monthly / yearly
|
||||
{
|
||||
expr: "0 0 1 * *",
|
||||
after: utc(2026, 4, 11),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 5, 1, 0, 0),
|
||||
label: "first of month",
|
||||
},
|
||||
{
|
||||
expr: "@monthly",
|
||||
after: utc(2026, 4, 11),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 5, 1, 0, 0),
|
||||
label: "@monthly macro",
|
||||
},
|
||||
{
|
||||
expr: "0 0 1 1 *",
|
||||
after: utc(2026, 0, 2),
|
||||
tz: "UTC",
|
||||
expected: utc(2027, 0, 1, 0, 0),
|
||||
label: "annual Jan 1",
|
||||
},
|
||||
{
|
||||
expr: "@yearly",
|
||||
after: utc(2026, 0, 2),
|
||||
tz: "UTC",
|
||||
expected: utc(2027, 0, 1, 0, 0),
|
||||
label: "@yearly macro",
|
||||
},
|
||||
|
||||
// Month aliases
|
||||
{
|
||||
expr: "0 0 1 JUL *",
|
||||
after: utc(2026, 4, 11),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 6, 1, 0, 0),
|
||||
label: "month alias JUL",
|
||||
},
|
||||
|
||||
// 4 specific minutes
|
||||
{
|
||||
expr: "0,15,30,45 9-17 * * MON-FRI",
|
||||
after: utc(2026, 4, 11, 9, 16),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 9, 30),
|
||||
label: "quarter-hour business hours",
|
||||
},
|
||||
{
|
||||
expr: "0,15,30,45 9-17 * * MON-FRI",
|
||||
after: utc(2026, 4, 11, 17, 46),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 12, 9, 0),
|
||||
label: "wrap to next business day",
|
||||
},
|
||||
|
||||
// Range/step variations
|
||||
{
|
||||
expr: "0 10-12/2 * * *",
|
||||
after: utc(2026, 4, 11, 9, 0),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 10, 0),
|
||||
label: "step in range first",
|
||||
},
|
||||
{
|
||||
expr: "0 10-12/2 * * *",
|
||||
after: utc(2026, 4, 11, 11, 0),
|
||||
tz: "UTC",
|
||||
expected: utc(2026, 4, 11, 12, 0),
|
||||
label: "step in range second",
|
||||
},
|
||||
|
||||
// Leap year
|
||||
{
|
||||
expr: "0 0 29 2 *",
|
||||
after: utc(2026, 0, 1),
|
||||
tz: "UTC",
|
||||
expected: utc(2028, 1, 29, 0, 0),
|
||||
label: "leap day next",
|
||||
},
|
||||
|
||||
// DST — America/New_York 2026-03-08 spring-forward
|
||||
{
|
||||
expr: "0 3 * * *",
|
||||
after: utc(2026, 2, 8, 6, 0),
|
||||
tz: "America/New_York",
|
||||
// 03:00 EDT on 2026-03-08 = 07:00 UTC
|
||||
expected: utc(2026, 2, 8, 7, 0),
|
||||
label: "NY 3am day-of-spring-forward",
|
||||
},
|
||||
|
||||
// Europe/London non-DST anchor
|
||||
// 2026-01-01 09:00 London = 09:00 UTC (GMT in January)
|
||||
{
|
||||
expr: "0 9 * * *",
|
||||
after: utc(2025, 11, 31, 23, 0),
|
||||
tz: "Europe/London",
|
||||
expected: utc(2026, 0, 1, 9, 0),
|
||||
label: "London winter 9am",
|
||||
},
|
||||
|
||||
// Australia/Sydney winter 12:00 local = 02:00 UTC (UTC+10).
|
||||
// after = 2026-05-31T00:00Z = 2026-05-31T10:00 Sydney, next noon
|
||||
// Sydney = 2026-05-31T12:00 local = 2026-05-31T02:00 UTC.
|
||||
{
|
||||
expr: "0 12 * * *",
|
||||
after: utc(2026, 4, 31, 0, 0),
|
||||
tz: "Australia/Sydney",
|
||||
expected: utc(2026, 4, 31, 2, 0),
|
||||
label: "Sydney winter noon",
|
||||
},
|
||||
|
||||
// Seconds + Quartz variants (need seconds: true)
|
||||
// Tested separately because parseCron options differ.
|
||||
];
|
||||
|
||||
describe("vectors — 5-field UTC + DST", () => {
|
||||
for (const v of vectors) {
|
||||
it(v.label, { timeout: 5000 }, () => {
|
||||
const expr = parseCron(v.expr);
|
||||
const next = expr.nextRun(v.after, v.tz);
|
||||
expect(next).toBe(v.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("vectors — 6-field seconds", () => {
|
||||
it("every 10 seconds", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("*/10 * * * * *", { seconds: true });
|
||||
expect(expr.nextRun(utc(2026, 4, 11, 10, 0, 5), "UTC")).toBe(
|
||||
utc(2026, 4, 11, 10, 0, 10)
|
||||
);
|
||||
});
|
||||
|
||||
it("seconds with hour wrap", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("30 0 * * * *", { seconds: true });
|
||||
expect(expr.nextRun(utc(2026, 4, 11, 10, 0, 30), "UTC")).toBe(
|
||||
utc(2026, 4, 11, 11, 0, 30)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vectors — 7-field Quartz year", () => {
|
||||
it("year-constrained next run", { timeout: 5000 }, () => {
|
||||
const expr = parseCron("0 0 12 1 1 * 2030", {
|
||||
seconds: true,
|
||||
years: true,
|
||||
});
|
||||
expect(expr.nextRun(utc(2026, 0, 1), "UTC")).toBe(
|
||||
utc(2030, 0, 1, 12, 0, 0)
|
||||
);
|
||||
});
|
||||
});
|
||||
7
packages/foundation/timing/cron/tsconfig.lib.json
Normal file
7
packages/foundation/timing/cron/tsconfig.lib.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": { "rootDir": "./src", "outDir": "./dist", "lib": ["ES2022"], "types": ["node"] },
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
17
packages/foundation/timing/cron/vite.config.ts
Normal file
17
packages/foundation/timing/cron/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/cron",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist", emptyOutDir: true, reportCompressedSize: true, sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: ["@suaveplan/error", "@suaveplan/types", "tslib", "zod", "@opentelemetry/api", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
17
packages/foundation/timing/cron/vitest.config.ts
Normal file
17
packages/foundation/timing/cron/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false, environment: "node", pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul", reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/*.schemas.ts", "src/**/*.types.ts", "src/**/index.ts", "tests/**"],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user