Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,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"
]
}

View File

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

View File

@@ -0,0 +1,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);
});
});

View 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();

View File

@@ -0,0 +1,6 @@
export type { CronErrorCode } from "./cron-error.js";
export {
CRON_CODES,
CronError,
registerCronErrorCodes,
} from "./cron-error.js";

View File

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

View File

@@ -0,0 +1,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);

View 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 });
});
});

View 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);
}

View File

@@ -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>>;

View File

@@ -0,0 +1,6 @@
export { parseField, rangeFor } from "./cron-fields.js";
export type {
FieldAliases,
FieldKind,
FieldRange,
} from "./cron-fields.types.js";

View 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";

View File

@@ -0,0 +1,4 @@
export {
expandMacro,
macroTable,
} from "./macros.js";

View File

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

View File

@@ -0,0 +1,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 * * * *");
});
});

View 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;
}

View File

@@ -0,0 +1,4 @@
export {
computeNextRun,
computePreviousRun,
} from "./next-run.js";

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

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

View File

@@ -0,0 +1,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));
});
});

View 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);
}

View File

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

View File

@@ -0,0 +1,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);
});
});

View 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 });
}

View File

@@ -0,0 +1,6 @@
export type { Outcome } from "./cron-otel.js";
export {
recordDstTransition,
recordNextRun,
recordParse,
} from "./cron-otel.js";

View 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";

View File

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

View File

@@ -0,0 +1,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);

View 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);
});
});

View 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 };

View File

@@ -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;
}

View File

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

View File

@@ -0,0 +1,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(),
});

View File

@@ -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();
});
});

View 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);
}

View File

@@ -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;
}

View 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";

View File

@@ -0,0 +1,7 @@
export type { WallClock } from "./timezone.js";
export {
assertTimezone,
detectDstTransition,
fromWallClock,
toWallClock,
} from "./timezone.js";

View File

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

View 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);
});
});

View 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);
}

View 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);
});
});

View 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 }
);
});
});

View File

@@ -0,0 +1 @@
export {};

View 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)
);
});
});

View 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"]
}

View 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:/],
},
},
});

View 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,
},
});