first commit
This commit is contained in:
15
docs/foundation/timing/README.md
Normal file
15
docs/foundation/timing/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# `timing`
|
||||
|
||||
Cron, stopwatches, throttling, debouncing, and scheduling.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `timing`
|
||||
|
||||
3 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cron`](./cron/README.md) | Universal cron expression parser, next-fire calculator with timezone + DST awareness, and an opt-in lightweight in-process scheduler. Zero external cron depend… |
|
||||
| [`@suaveplan/datetime`](./datetime/README.md) | Timezone-aware date/time utilities covering the most common application-layer date operations that the native `Date` API leaves unaddressed. The package provid… |
|
||||
| [`@suaveplan/timing`](./timing/README.md) | Timing and scheduling utilities for application-layer time management. The package covers measurement (stopwatch with lap recording), rate control (throttle an… |
|
||||
<!-- end:auto-docs -->
|
||||
1
docs/foundation/timing/_intro.md
Normal file
1
docs/foundation/timing/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Cron, stopwatches, throttling, debouncing, and scheduling.
|
||||
144
docs/foundation/timing/cron/README.md
Normal file
144
docs/foundation/timing/cron/README.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# @suaveplan/cron - Universal cron expression parser, next-fire calculator with timezone + DST support, and lightweight in-process scheduler
|
||||
|
||||
## Summary
|
||||
|
||||
Universal cron expression parser, next-fire calculator with
|
||||
timezone + DST awareness, and an opt-in lightweight in-process
|
||||
scheduler. Zero external cron dependencies — written natively against
|
||||
`@suaveplan/error`, `@suaveplan/types`, and `Intl.DateTimeFormat`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [errors](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ - [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ - [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…
|
||||
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ - [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ - [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Quick start
|
||||
|
||||
```typescript
|
||||
import { parseCron, isValidCron } from "@suaveplan/cron";
|
||||
|
||||
const expr = parseCron("*/15 * * * *");
|
||||
console.log(expr.fields.minutes); // [0, 15, 30, 45]
|
||||
|
||||
const next = expr.nextRun(Date.UTC(2026, 4, 11, 10, 7, 0), "America/New_York");
|
||||
console.log(new Date(next as number).toISOString());
|
||||
|
||||
console.log(isValidCron("0 9 * * MON-FRI")); // true
|
||||
console.log(isValidCron("garbage")); // false
|
||||
```
|
||||
|
||||
## Scheduler example
|
||||
|
||||
```typescript
|
||||
import { CronScheduler } from "@suaveplan/cron";
|
||||
|
||||
const scheduler = new CronScheduler({
|
||||
onError: (err, id) => console.error(`job ${id} failed`, err),
|
||||
});
|
||||
|
||||
scheduler.add("0 9 * * MON-FRI", async () => sendDailyDigest(), {
|
||||
tz: "America/New_York",
|
||||
});
|
||||
scheduler.add("@hourly", () => collectMetrics());
|
||||
|
||||
scheduler.start();
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await scheduler.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
## FakeClock test recipe
|
||||
|
||||
```typescript
|
||||
import { CronScheduler } from "@suaveplan/cron";
|
||||
import type { SchedulerTimers } from "@suaveplan/cron";
|
||||
|
||||
const now = { value: Date.UTC(2026, 4, 11, 9, 59, 0) };
|
||||
const pending = new Map<number, { cb: () => void; fireAt: number }>();
|
||||
let nextId = 1;
|
||||
|
||||
const timers: SchedulerTimers = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = nextId++;
|
||||
pending.set(id, { cb, fireAt: now.value + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => pending.delete(id),
|
||||
};
|
||||
|
||||
const scheduler = new CronScheduler({ clock: () => now.value, timers });
|
||||
scheduler.add("0 * * * *", () => console.log("fired"));
|
||||
scheduler.start();
|
||||
|
||||
// Fast-forward 1 hour.
|
||||
now.value += 60 * 60 * 1000;
|
||||
for (const [id, { cb, fireAt }] of pending) {
|
||||
if (fireAt <= now.value) { pending.delete(id); cb(); }
|
||||
}
|
||||
|
||||
await scheduler.stop();
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Surface | Purpose |
|
||||
| -------------------------------- | ------------------------------------------------------------------ |
|
||||
| `parseCron(expr, options?)` | Parse a 5/6/7-field expression into a frozen `CronExpression`. |
|
||||
| `isValidCron(expr, options?)` | Non-throwing parse check. |
|
||||
| `CronExpression.nextRun` | Next epoch instant strictly greater than `after`. |
|
||||
| `CronExpression.nextRuns` | Strictly monotonic array of `count` fire times. |
|
||||
| `CronExpression.previousRun` | Largest epoch instant strictly less than `before`. |
|
||||
| `CronExpression.matches` | Constant-time wall-clock membership check. |
|
||||
| `CronScheduler` | Lightweight in-process driver (no queue/retry/concurrency). |
|
||||
| `createCronScheduler` | Factory mirror. |
|
||||
| `parseField`, `rangeFor` | Single-field tokenizer + per-kind range. |
|
||||
| `expandMacro`, `macroTable` | Macro shortcut expansion (`@daily`, `@hourly`, …). |
|
||||
| `toWallClock`, `fromWallClock` | Bidirectional epoch ↔ wall-clock conversion in any IANA zone. |
|
||||
| `assertTimezone` | Eager IANA-zone validation. |
|
||||
| `detectDstTransition` | DST classifier between two adjacent instants. |
|
||||
| `CronError`, `CRON_CODES` | Typed error class + frozen code registry. |
|
||||
| `recordParse`, `recordNextRun` | OpenTelemetry instrumentation helpers. |
|
||||
|
||||
## Why not croner
|
||||
|
||||
Croner is excellent reference material but ships as an opaque tarball,
|
||||
imposes its own API conventions, and is not universal in the
|
||||
sense. `@suaveplan/cron` lives at the same primitive
|
||||
tier as `@suaveplan/datetime` and `@suaveplan/timing`: zero external
|
||||
runtime deps beyond the foundation, typed errors via
|
||||
`defineCodes`, Zod boundary schemas, an OTel surface, and the standard
|
||||
five-files-per-feature layout. Splitting cron parsing out of the
|
||||
job-scheduler keeps the workflow-tier scheduler focused on queue /
|
||||
retry / concurrency concerns while letting validation packages and
|
||||
browser UIs consume parsing without dragging in the runtime.
|
||||
|
||||
## Coverage
|
||||
|
||||
100 % Istanbul coverage on every implementation file is gated by CI:
|
||||
|
||||
```bash
|
||||
bunx turbo test:coverage --filter=@suaveplan/cron
|
||||
```
|
||||
|
||||
80
docs/foundation/timing/cron/src/errors/cron-error.md
Normal file
80
docs/foundation/timing/cron/src/errors/cron-error.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# CronError
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`CronError` is the single typed error class emitted by every public entry
|
||||
point in `@suaveplan/cron`. It extends `SuaveplanError` with the literal
|
||||
`CronErrorCode` union so `instanceof CronError` plus `err.code` lets
|
||||
consumers branch on a finite, registered set of failure modes without
|
||||
parsing free-form `message` strings. The companion `CRON_CODES` registry is
|
||||
built via `defineCodes("CRON", [...])` so each fully-qualified code
|
||||
(`CRON.INVALID_EXPRESSION`, etc.) is generated once and frozen at module
|
||||
load.
|
||||
|
||||
## Features
|
||||
|
||||
- Five typed codes covering every documented failure mode: invalid
|
||||
expression, field-out-of-range numerics, unsupported syntax (`L`/`W`/`#`),
|
||||
invalid IANA timezone names, and exhausted next-run search horizons.
|
||||
- Eager registration via `registerCronErrorCodes()` runs at module load.
|
||||
- Runtime guard in the public constructor rejects unknown codes — defends
|
||||
against JSON-deserialised payloads injecting unregistered codes.
|
||||
- Cause-chaining via `SuaveplanError` (`options.cause`) and structured
|
||||
`context` for telemetry redaction.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CRON_CODES, CronError } from "@suaveplan/cron";
|
||||
|
||||
try {
|
||||
throw new CronError(CRON_CODES.INVALID_EXPRESSION, "cron.invalid_expression", {
|
||||
context: { expr: "* * *" },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof CronError && err.code === CRON_CODES.INVALID_EXPRESSION) {
|
||||
console.warn("rejected", err.context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { CRON_CODES, CronError } from "@suaveplan/cron";
|
||||
|
||||
function wrap(cause: Error): CronError {
|
||||
return new CronError(CRON_CODES.NEXT_RUN_NOT_FOUND, "cron.next_run_not_found", {
|
||||
cause,
|
||||
context: { horizonYears: 8 },
|
||||
});
|
||||
}
|
||||
|
||||
const wrapped = wrap(new Error("horizon exhausted"));
|
||||
console.log(wrapped.code, wrapped.cause);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CRON_CODES` — frozen record mapping reason → fully-qualified code.
|
||||
- `CronErrorCode` — string-literal union of every registered code.
|
||||
- `class CronError extends SuaveplanError<CronErrorCode>` — public
|
||||
constructor `(code, messageKey, options?)`. Throws `TypeError` for
|
||||
unregistered codes.
|
||||
- `registerCronErrorCodes(): number` — eager-registration probe that
|
||||
returns the number of registered codes. Safe to call any number of
|
||||
times — never has side effects beyond reading the frozen registry.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is captured into a `Set` once at module evaluation so the
|
||||
constructor guard is O(1). The guard runs before `super()` so a malformed
|
||||
code surfaces as a `TypeError` (not a half-constructed `CronError`).
|
||||
`registerCronErrorCodes` is invoked at module load and is also exported so
|
||||
callers performing dependency-injection tree-shaking inspection have an
|
||||
explicit hook. The error class is `final` in spirit — subclassing is not
|
||||
part of the public contract; consumers should always discriminate on
|
||||
`err.code`.
|
||||
89
docs/foundation/timing/cron/src/fields/cron-fields.md
Normal file
89
docs/foundation/timing/cron/src/fields/cron-fields.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# parseField
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`parseField` is the single-field tokenizer at the heart of cron parsing.
|
||||
Each of the five (or six/seven) whitespace-separated positions in a cron
|
||||
expression is independently expanded into the sorted, unique numeric set
|
||||
of values it matches. The function is total over its `FieldKind`
|
||||
discriminator: it accepts the canonical crontab syntax surface (literals,
|
||||
ranges, lists, steps, wildcards, and alphabetic aliases for month +
|
||||
day-of-week) and rejects every Quartz extension (`L`/`W`/`#`/`?`) with
|
||||
typed errors so the caller can branch on `err.code`.
|
||||
|
||||
## Features
|
||||
|
||||
- Wildcards (`*`), literals, comma-separated lists, hyphenated ranges,
|
||||
step values (`*/n` and `a-b/n` and `a/n`), case-insensitive aliases
|
||||
(`JAN`-`DEC` for month, `SUN`-`SAT` for day-of-week).
|
||||
- Day-of-week normalization: both `0` and `7` are accepted for Sunday and
|
||||
collapse to `0` after sorting and deduplication.
|
||||
- Range bounds per field exposed via `rangeFor(kind)` for downstream
|
||||
next-run computations.
|
||||
- Typed errors via `CronError`: out-of-range numerics, unsupported tokens,
|
||||
and malformed atoms each surface a distinct `CronErrorCode`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { parseField } from "@suaveplan/cron";
|
||||
|
||||
const minutes = parseField("*/15", "minute");
|
||||
console.log(Array.from(minutes)); // [0, 15, 30, 45]
|
||||
|
||||
const weekdays = parseField("MON-FRI", "dayOfWeek");
|
||||
console.log(Array.from(weekdays)); // [1, 2, 3, 4, 5]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { parseField, rangeFor, CronError, CRON_CODES } from "@suaveplan/cron";
|
||||
|
||||
function expandOrFallback(token: string, kind: Parameters<typeof parseField>[1]) {
|
||||
try {
|
||||
return parseField(token, kind);
|
||||
} catch (err) {
|
||||
if (err instanceof CronError && err.code === CRON_CODES.UNSUPPORTED_SYNTAX) {
|
||||
const range = rangeFor(kind);
|
||||
return Object.freeze(
|
||||
Array.from({ length: range.max - range.min + 1 }, (_, i) => range.min + i)
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(expandOrFallback("L", "dayOfMonth")); // falls back to full range
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseField(token: string, kind: FieldKind): readonly number[]` — returns a
|
||||
frozen, sorted, deduplicated value set. Throws `CronError` with codes
|
||||
`CRON.UNSUPPORTED_SYNTAX`, `CRON.FIELD_OUT_OF_RANGE`, or
|
||||
`CRON.INVALID_EXPRESSION` per the failure mode.
|
||||
- `rangeFor(kind: FieldKind): FieldRange` — inclusive `{min, max}` for the
|
||||
given field kind.
|
||||
- `FieldKind` — `"second" | "minute" | "hour" | "dayOfMonth" | "month" |
|
||||
"dayOfWeek" | "year"`.
|
||||
- `FieldRange` — `{ readonly min: number; readonly max: number }`.
|
||||
- `FieldAliases` — `Readonly<Record<string, number>>` (internal alias map
|
||||
shape).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tokenizer walks comma-separated terms left-to-right, dispatching each
|
||||
term through the step parser before falling through to the range parser.
|
||||
Step extraction is bounded to a single `/` per term; nested steps
|
||||
(`*/15/2`) are rejected up front. Range expansion is O(n) over the result
|
||||
set and feeds a `Set` for deduplication before a final monotonically
|
||||
sorted `Array.from`. Day-of-week values are normalized after expansion so
|
||||
both `0,7` and `SAT,SUN` collapse correctly. Unsupported Quartz tokens
|
||||
(`L`/`W`/`#`/`?`) are caught by a single regex before any further parsing
|
||||
to keep the error path cheap; alias resolution is case-insensitive via
|
||||
`toUpperCase`. The output array is `Object.freeze`d to prevent downstream
|
||||
mutation.
|
||||
78
docs/foundation/timing/cron/src/macros/macros.md
Normal file
78
docs/foundation/timing/cron/src/macros/macros.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# expandMacro
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`expandMacro` recognises the conventional crontab macro shortcuts —
|
||||
`@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`,
|
||||
`@hourly` — and expands them to their canonical 5-field cron equivalents.
|
||||
Macro expansion runs before field tokenization so the orchestrator only
|
||||
has to know about the structural 5/6/7-field shape. The function is
|
||||
deliberately conservative: it never silently rewrites non-macro input,
|
||||
and it explicitly rejects `@reboot` (which has no wall-clock semantics in
|
||||
a persistent process) with a typed `CRON.UNSUPPORTED_SYNTAX` error.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven supported macros mapped to their POSIX-compatible 5-field
|
||||
expansions.
|
||||
- Case-insensitive (`@DAILY` and `@daily` are both accepted).
|
||||
- Whitespace-tolerant (leading and trailing whitespace stripped).
|
||||
- Returns `null` for non-macro inputs so the orchestrator can fall through
|
||||
to the standard field tokenizer.
|
||||
- Throws `CronError(CRON.UNSUPPORTED_SYNTAX)` for `@reboot`.
|
||||
- Throws `CronError(CRON.INVALID_EXPRESSION)` for unknown `@` shortcuts.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { expandMacro } from "@suaveplan/cron";
|
||||
|
||||
console.log(expandMacro("@daily")); // "0 0 * * *"
|
||||
console.log(expandMacro("@hourly")); // "0 * * * *"
|
||||
console.log(expandMacro("0 * * * *")); // null — not a macro
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { expandMacro, CRON_CODES, CronError } from "@suaveplan/cron";
|
||||
|
||||
function canonicalise(input: string): string {
|
||||
try {
|
||||
return expandMacro(input) ?? input;
|
||||
} catch (err) {
|
||||
if (err instanceof CronError && err.code === CRON_CODES.UNSUPPORTED_SYNTAX) {
|
||||
// Persistent process — fall back to "every 24 hours".
|
||||
return "0 0 * * *";
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(canonicalise("@reboot")); // "0 0 * * *"
|
||||
console.log(canonicalise("@weekly")); // "0 0 * * 0"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `expandMacro(expr: string): string | null` — returns the 5-field
|
||||
expansion for a recognised macro, `null` for non-macro input, and
|
||||
throws `CronError` for unknown or unsupported `@` shortcuts.
|
||||
- `macroTable(): Readonly<Record<string, string>>` — returns the frozen
|
||||
canonical lookup table used internally. Useful for documentation
|
||||
generation and admin UIs that render the supported macro set.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The macro table is `Object.freeze`d at module load to guard against
|
||||
accidental mutation. Lookup is by lower-cased trimmed key so the function
|
||||
is O(1). `@reboot` is explicitly intercepted before the table lookup
|
||||
because its semantics differ fundamentally from the other macros — it
|
||||
fires at process start, not at a wall-clock instant, and is therefore not
|
||||
representable as a 5-field expansion. Returning `null` for non-macro
|
||||
input (rather than throwing) is deliberate so the orchestrator can
|
||||
unconditionally invoke `expandMacro` before its own field tokenizer
|
||||
without paying for exception construction on the hot path.
|
||||
99
docs/foundation/timing/cron/src/next-run/next-run-fields.md
Normal file
99
docs/foundation/timing/cron/src/next-run/next-run-fields.md
Normal 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.
|
||||
77
docs/foundation/timing/cron/src/next-run/next-run.md
Normal file
77
docs/foundation/timing/cron/src/next-run/next-run.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# next-run
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`computeNextRun` and `computePreviousRun` drive every public next-fire
|
||||
query in `@suaveplan/cron`: they take a parsed `CronFields` set, a
|
||||
reference epoch instant, and an IANA timezone, and return the closest
|
||||
matching wall-clock instant in the requested direction. They implement
|
||||
the bounded search horizon mandated by the spec — pathological
|
||||
expressions (e.g. `0 0 31 2 *`, asking for February 31st) terminate with
|
||||
`CronError(CRON.NEXT_RUN_NOT_FOUND)` rather than looping. DST transitions
|
||||
are detected as the search crosses an offset boundary and emitted via
|
||||
the OpenTelemetry counter.
|
||||
|
||||
## Features
|
||||
|
||||
- Bounded 8-year forward and backward horizons.
|
||||
- DST-aware: results land on the next existing wall-clock instant during
|
||||
spring-forward gaps and fire exactly once across fall-back overlaps
|
||||
(first occurrence wins).
|
||||
- OTel histograms + counters labelled by outcome and timezone.
|
||||
- Vixie OR semantics for day-of-month vs. day-of-week handled at the
|
||||
per-day check.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { parseCron } from "@suaveplan/cron";
|
||||
|
||||
const expr = parseCron("0 9 * * 1-5");
|
||||
const next = expr.nextRun(Date.UTC(2026, 4, 11, 5, 0, 0), "America/New_York");
|
||||
console.log(new Date(next as number).toISOString());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { parseCron } from "@suaveplan/cron";
|
||||
|
||||
const expr = parseCron("0 2 * * *");
|
||||
// 2026-03-08 in NY: clock jumps from 02:00 → 03:00. The 02:00 slot is
|
||||
// skipped; the engine resolves to the next valid local instant.
|
||||
const after = Date.UTC(2026, 2, 8, 6, 0, 0);
|
||||
const next = expr.nextRun(after, "America/New_York");
|
||||
console.log(new Date(next as number).toISOString());
|
||||
// 2026-03-08T07:00:00.000Z — 03:00 local EDT.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `computeNextRun(fields: CronFields, after: number, tz: string): number`
|
||||
— throws `CronError(CRON.NEXT_RUN_NOT_FOUND)` on horizon exhaustion;
|
||||
always returns a value strictly greater than `after`.
|
||||
- `computePreviousRun(fields: CronFields, before: number, tz: string):
|
||||
number` — mirror of the forward search. Same horizon and error.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The forward search advances the cursor by one minute (or one second when
|
||||
the seconds field is present) per iteration, snaps the wall-clock to the
|
||||
next legal slot via the helpers in `next-run-fields.ts`, converts back
|
||||
to epoch via `fromWallClock`, and re-reads the resulting wall-clock to
|
||||
verify. The verify-after-snap loop is the key DST-handling primitive:
|
||||
when the requested wall-clock falls into a spring-forward gap,
|
||||
`fromWallClock` snaps to the first existing real instant, and the verify
|
||||
step rejects that candidate if it no longer matches the expression — the
|
||||
cursor then bumps forward by another minute and the loop retries until a
|
||||
matching instant is found inside the 8-year horizon. Fall-back is handled
|
||||
implicitly because we always pick the first epoch instant whose
|
||||
wall-clock matches; the second occurrence of the repeated hour is then
|
||||
strictly greater and not re-emitted. The backward search mirrors the
|
||||
same pattern with a hard iteration cap (`366 × 24 × 60 × HORIZON_YEARS`)
|
||||
so an expression with no historical match terminates rather than
|
||||
livelocks.
|
||||
80
docs/foundation/timing/cron/src/otel/cron-otel.md
Normal file
80
docs/foundation/timing/cron/src/otel/cron-otel.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# cron-otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`cron-otel` is the OpenTelemetry instrumentation surface for the
|
||||
`@suaveplan/cron` package. It centralises every metric emitted by the
|
||||
parser, the next-run engine, and the in-process scheduler so cardinality
|
||||
stays bounded and label semantics stay consistent. Each helper is a thin,
|
||||
no-throw wrapper around the global `metrics.getMeter("@suaveplan/cron")`
|
||||
instruments and is safe to call on every hot-path invocation — the
|
||||
underlying instrument is lazily constructed and then cached.
|
||||
|
||||
## Features
|
||||
|
||||
- Two histograms: `cron.parse.duration_ms` (outcome) and
|
||||
`cron.next_run.duration_ms` (outcome + tz).
|
||||
- Three counters: `cron.parse_total`, `cron.next_run_total`, and
|
||||
`cron.dst_transition_total`.
|
||||
- Closed-enum `outcome` and `kind` attributes to keep cardinality
|
||||
bounded; `tz` is the caller-supplied IANA name pass-through.
|
||||
- Lazy meter construction — no metrics work happens until first record.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { recordParse, recordNextRun } from "@suaveplan/cron";
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
// …parse cron expression…
|
||||
recordParse(Date.now() - t0, "success");
|
||||
} catch (err) {
|
||||
recordParse(Date.now() - t0, "error");
|
||||
throw err;
|
||||
}
|
||||
|
||||
recordNextRun(0.5, "success", "America/New_York");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { recordDstTransition } from "@suaveplan/cron";
|
||||
|
||||
function instrumentNextRun(detected: "spring-forward" | "fall-back" | null, tz: string) {
|
||||
if (detected !== null) {
|
||||
recordDstTransition(detected, tz);
|
||||
}
|
||||
}
|
||||
|
||||
instrumentNextRun("spring-forward", "America/New_York");
|
||||
instrumentNextRun("fall-back", "Europe/London");
|
||||
instrumentNextRun(null, "UTC"); // no-op
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordParse(durationMs: number, outcome: "success" | "error"): void`.
|
||||
- `recordNextRun(durationMs: number, outcome: "success" | "error",
|
||||
tz: string): void`.
|
||||
- `recordDstTransition(kind: "spring-forward" | "fall-back",
|
||||
tz: string): void`.
|
||||
- `type Outcome = "success" | "error"` — exported helper alias.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Each instrument is materialised through `metrics.getMeter(...)` only on
|
||||
the first record call. Subsequent invocations reuse the module-scoped
|
||||
cached handle, so the hot path is a single `record`/`add` call on the
|
||||
shared instrument. The OpenTelemetry global meter provider is read each
|
||||
time — that's a no-op when no provider is installed, which lets the
|
||||
package ship metrics-ready without forcing consumers to wire up an SDK.
|
||||
Attribute cardinality is held below practical limits: `outcome` and
|
||||
`kind` are closed unions, and `tz` is passed through unmodified from the
|
||||
caller (consumers control their own tz vocabulary). The package does not
|
||||
emit logs or spans — those concerns live in higher-tier consumers like
|
||||
`@suaveplan/job-scheduler`.
|
||||
86
docs/foundation/timing/cron/src/parse/parse-cron.md
Normal file
86
docs/foundation/timing/cron/src/parse/parse-cron.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# parseCron
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`parseCron` is the top-level orchestrator that consumes a raw cron
|
||||
expression string and returns a frozen `CronExpression` whose `nextRun`,
|
||||
`nextRuns`, `previousRun`, and `matches` methods drive every scheduling
|
||||
query. The orchestrator macro-expands the input first, then dispatches
|
||||
the resulting tokens through `parseField` per position, applies optional
|
||||
Vixie strict-DoW semantics, and finally binds the result to the search
|
||||
engine in `next-run.ts`. `isValidCron` is the non-throwing sibling: it
|
||||
returns a boolean and guarantees no exception escape for any string
|
||||
input, as mandated by the spec.
|
||||
|
||||
## Features
|
||||
|
||||
- 5-field default; 6-field opt-in via `seconds: true`; 7-field Quartz
|
||||
opt-in via both `seconds` and `years` true.
|
||||
- Macro expansion (`@daily`, `@hourly`, ...) handled before tokenization.
|
||||
- `strictDoW` option enforces "either dom or dow, never both" Vixie
|
||||
semantics.
|
||||
- `nextRuns(count)` produces a strictly monotonic array.
|
||||
- `matches(when, tz)` is a constant-time membership check against the
|
||||
parsed field sets.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { parseCron, isValidCron } from "@suaveplan/cron";
|
||||
|
||||
const expr = parseCron("*/15 * * * *");
|
||||
console.log(expr.fields.minutes); // [0, 15, 30, 45]
|
||||
console.log(expr.nextRun(Date.UTC(2026, 4, 11, 10, 7, 0), "UTC"));
|
||||
// Date.UTC(2026, 4, 11, 10, 15, 0)
|
||||
|
||||
console.log(isValidCron("not a cron expression")); // false
|
||||
console.log(isValidCron("0 9 * * MON-FRI")); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { parseCron } from "@suaveplan/cron";
|
||||
|
||||
const quartz = parseCron("0 0 12 * * MON-FRI 2026-2030", {
|
||||
seconds: true,
|
||||
years: true,
|
||||
});
|
||||
const runs = quartz.nextRuns(3, Date.UTC(2026, 0, 1), "America/New_York");
|
||||
console.log(runs.map((t) => new Date(t).toISOString()));
|
||||
|
||||
const strict = parseCron("0 12 15 * 1", { strictDoW: true });
|
||||
// Rejects expressions where dom AND dow are both restricted.
|
||||
console.log(strict.matches(Date.UTC(2026, 0, 15, 12, 0, 0), "UTC"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseCron(expr: string, options?: ParseOptions): CronExpression` —
|
||||
parses and returns the frozen expression handle.
|
||||
- `isValidCron(expr: string, options?: ParseOptions): boolean` — never
|
||||
throws.
|
||||
- `interface CronExpression { expr; fields; nextRun; nextRuns;
|
||||
previousRun; matches }`.
|
||||
- `interface ParseOptions { seconds?; years?; strictDoW? }`.
|
||||
- `interface CronFields { seconds?; minutes; hours; daysOfMonth; months;
|
||||
daysOfWeek; years? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The orchestrator first invokes `expandMacro` so the rest of the pipeline
|
||||
only ever sees canonical 5/6/7-field strings. Tokenization is whitespace-
|
||||
based with the field count strictly validated against `ParseOptions`.
|
||||
Each field is independently parsed via the shared `parseField`
|
||||
tokenizer, which returns a sorted, deduplicated `readonly number[]`.
|
||||
`strictDoW` enforcement runs after tokenization so the error context
|
||||
carries the actual parsed sets for debug visibility. The returned
|
||||
`CronExpression` is `Object.freeze`d and binds its methods over the
|
||||
captured fields, so destructuring (`const { nextRun } = parseCron(...)`)
|
||||
is safe. `isValidCron` is a tight try/catch around the same code path so
|
||||
the two functions stay in lock-step with no semantic drift. All parse
|
||||
calls flow through `recordParse` so an outcome attribute lands on every
|
||||
invocation regardless of success or failure.
|
||||
109
docs/foundation/timing/cron/src/scheduler/cron-scheduler.md
Normal file
109
docs/foundation/timing/cron/src/scheduler/cron-scheduler.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# CronScheduler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`CronScheduler` is a deliberately minimal in-process cron driver. It
|
||||
parses each registered expression once via `parseCron`, schedules a
|
||||
single `setTimeout` per job pointing at the next computed fire time, and
|
||||
re-schedules immediately after each handler completes. Queueing,
|
||||
retries, concurrency limits, persistence, and lifecycle hooks are
|
||||
intentionally out of scope — that's `@suaveplan/job-scheduler`'s
|
||||
responsibility. This class is small enough to embed inside a CLI, a
|
||||
browser admin panel, or a serverless worker without dragging in the
|
||||
full job-runner footprint.
|
||||
|
||||
## Features
|
||||
|
||||
- `add(expr, handler, options?)` returns a string id (auto-generated via
|
||||
`crypto.randomUUID()` when omitted).
|
||||
- `remove(id)` clears the timer and removes the job.
|
||||
- `list()` snapshots every registered job with its next-run time.
|
||||
- `start()` / `stop()` are idempotent; `stop()` awaits every in-flight
|
||||
handler before resolving.
|
||||
- Handler errors route to `options.onError` when provided, otherwise are
|
||||
silently swallowed.
|
||||
- Injectable `Clock` and injectable `{ setTimer, clearTimer }` pair for
|
||||
fast, deterministic tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { CronScheduler } from "@suaveplan/cron";
|
||||
|
||||
const scheduler = new CronScheduler();
|
||||
scheduler.add("0 * * * *", async () => {
|
||||
console.log("hourly tick");
|
||||
});
|
||||
scheduler.start();
|
||||
|
||||
// later…
|
||||
await scheduler.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createCronScheduler } from "@suaveplan/cron";
|
||||
|
||||
let fakeNow = Date.UTC(2026, 4, 11, 9, 59, 0);
|
||||
const pending = new Map<number, { cb: () => void; fireAt: number }>();
|
||||
let nextTimerId = 1;
|
||||
|
||||
const scheduler = createCronScheduler({
|
||||
clock: () => fakeNow,
|
||||
timers: {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = nextTimerId++;
|
||||
pending.set(id, { cb, fireAt: fakeNow + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id) => pending.delete(id),
|
||||
},
|
||||
onError: (err, id) => console.error(`job ${id}`, err),
|
||||
});
|
||||
|
||||
scheduler.add("0 * * * *", () => console.log("fired"));
|
||||
scheduler.start();
|
||||
|
||||
// fast-forward
|
||||
fakeNow = Date.UTC(2026, 4, 11, 10, 0, 1);
|
||||
for (const [id, { cb, fireAt }] of pending) {
|
||||
if (fireAt <= fakeNow) {
|
||||
pending.delete(id);
|
||||
cb();
|
||||
}
|
||||
}
|
||||
await scheduler.stop();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class CronScheduler` with `constructor(options?: CronSchedulerOptions)`.
|
||||
- `add(expr: string, handler: () => void | Promise<void>,
|
||||
options?: CronSchedulerAddOptions): string`.
|
||||
- `remove(id: string): boolean`.
|
||||
- `list(): readonly CronSchedulerJob[]`.
|
||||
- `start(): void`.
|
||||
- `stop(): Promise<void>`.
|
||||
- `createCronScheduler(options?): CronScheduler` — factory mirror.
|
||||
- `CronSchedulerOptions`, `CronSchedulerAddOptions`, `CronSchedulerJob`,
|
||||
`SchedulerTimers` types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Each job tracks at most one outstanding timer id. `stop()` walks every
|
||||
job, clears its timer, and then `await`s the union of every promise in
|
||||
the `inFlight` set, so handlers mid-execution always run to completion
|
||||
before the returned promise resolves. Handler errors are caught inside
|
||||
`invokeHandler` and forwarded to `onError`; the post-handler
|
||||
re-scheduling continues unconditionally so a transient handler failure
|
||||
does not stop the job from firing again. The injectable `timers` pair
|
||||
is the testing seam: production callers get the real `setTimeout`, while
|
||||
tests bind it to a `FakeClock` so the integration suite can fire several
|
||||
hours of cron triggers in milliseconds. Job ids are auto-generated via
|
||||
`crypto.randomUUID` when available, falling back to a timestamp-plus-
|
||||
random string in environments that lack it, so the scheduler stays
|
||||
universal.
|
||||
88
docs/foundation/timing/cron/src/timezone/timezone.md
Normal file
88
docs/foundation/timing/cron/src/timezone/timezone.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# timezone
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The timezone module converts between epoch-millisecond timestamps and the
|
||||
wall-clock components observed in a specific IANA timezone using only the
|
||||
platform-native `Intl.DateTimeFormat` — no external timezone database is
|
||||
shipped or required. It powers the cron next-run algorithm by translating
|
||||
the user's "every day at 02:00 New York time" into the actual moments
|
||||
when that wall-clock instant occurs, including the awkward edge cases of
|
||||
daylight-saving-time spring-forward gaps and fall-back overlaps.
|
||||
|
||||
## Features
|
||||
|
||||
- `toWallClock(epochMs, tz)` — decompose an epoch instant into year /
|
||||
month / day / hour / minute / second / weekday in the requested zone.
|
||||
- `fromWallClock(components, tz)` — the inverse; performs up to four
|
||||
bounded fixed-point iterations to converge on the correct epoch even
|
||||
across DST transitions.
|
||||
- `detectDstTransition(before, after, tz)` — classifies a transition
|
||||
between two adjacent instants as `"spring-forward"`, `"fall-back"`, or
|
||||
`null` when no transition is crossed.
|
||||
- `assertTimezone(tz)` — eagerly validates an IANA name so the caller can
|
||||
fail fast at the API boundary.
|
||||
- Per-zone formatter cache keeps Intl construction off the hot path.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { toWallClock, fromWallClock } from "@suaveplan/cron";
|
||||
|
||||
const wall = toWallClock(Date.UTC(2026, 0, 1, 5, 0, 0), "America/New_York");
|
||||
console.log(wall.hour); // 0 — midnight in NY
|
||||
|
||||
const back = fromWallClock(
|
||||
{ year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 },
|
||||
"America/New_York"
|
||||
);
|
||||
console.log(back === Date.UTC(2026, 0, 1, 5, 0, 0)); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
detectDstTransition,
|
||||
fromWallClock,
|
||||
toWallClock,
|
||||
} from "@suaveplan/cron";
|
||||
|
||||
const start = Date.UTC(2026, 2, 8, 6, 0, 0);
|
||||
const end = Date.UTC(2026, 2, 8, 8, 0, 0);
|
||||
const transition = detectDstTransition(start, end, "America/New_York");
|
||||
console.log(transition); // "spring-forward"
|
||||
|
||||
const requested = { year: 2026, month: 3, day: 8, hour: 2, minute: 30, second: 0 };
|
||||
const real = fromWallClock(requested, "America/New_York");
|
||||
const observed = toWallClock(real, "America/New_York");
|
||||
console.log(observed.hour, observed.minute); // not 02:30 — the slot was skipped
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface WallClock` — `{ year, month, day, hour, minute, second, weekday }`
|
||||
with `weekday ∈ 0..6` (Sunday = 0, matching cron numbering).
|
||||
- `toWallClock(epochMs: number, tz: string): WallClock`.
|
||||
- `fromWallClock(components: Omit<WallClock, "weekday">, tz: string): number`.
|
||||
- `detectDstTransition(beforeEpochMs, afterEpochMs, tz):
|
||||
"spring-forward" | "fall-back" | null`.
|
||||
- `assertTimezone(tz: string): void` — throws
|
||||
`CronError(CRON.TIMEZONE_INVALID)` for invalid IANA names.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Intl.DateTimeFormat` is the only timezone authority. The package never
|
||||
ships a tz database — keeping it universal and dependency-free. The
|
||||
inverse `fromWallClock` works by guessing UTC, observing the wall clock
|
||||
offset that produces, and applying it back. Two iterations suffice for
|
||||
every IANA zone in normal time; we run up to four to handle the awkward
|
||||
spring-forward case where the first correction overshoots the gap.
|
||||
Inside the gap the function picks the canonical real-instant that
|
||||
`Intl.DateTimeFormat` reports, and callers re-verify against their field
|
||||
set so cron schedules that fall in the gap fire at the next existing
|
||||
minute. Formatter construction is cached per IANA name to keep the hot
|
||||
path allocation-free.
|
||||
92
docs/foundation/timing/datetime/README.md
Normal file
92
docs/foundation/timing/datetime/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# @suaveplan/datetime - Timezone-aware date/time utilities with business-day arithmetic, recurrence rules, calendar math, and relative time formatting
|
||||
|
||||
## Summary
|
||||
|
||||
Timezone-aware date/time utilities covering the most common application-layer date operations that the native `Date` API leaves unaddressed. The package provides calendar math (`addDuration`, `subtractDuration`, `startOf`, `endOf`, `getCalendarMonth`, leap-year detection), business-day arithmetic with configurable holiday calendars and custom weekend definitions, an RRULE-subset recurrence engine (DAILY/WEEKLY/MONTHLY/YEARLY with BYDAY/BYMONTH/COUNT/UNTIL), and human-readable relative time formatting (`"3 hours ago"`, `"in 2 days"`). All stateful entry points accept an injectable `Clock` for deterministic testing. Use this package for scheduling UIs, calendar components, invoice date calculations, and any domain where "next business day" or "every other Monday" has real meaning. For low-level stopwatches, throttle/debounce, or vector clocks use `@suaveplan/timing`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [calendar-boundary](./src/calendar/calendar-boundary.md) — `calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and…
|
||||
- [calendar-core](./src/calendar/calendar-core.md) — `calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput`…
|
||||
- [calendar-duration](./src/calendar/calendar-duration.md) — `calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from…
|
||||
- [calendar-grid](./src/calendar/calendar-grid.md) — `calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components…
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [datetime-error](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [datetime-otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [recurrence-generate](./src/recurrence/recurrence-generate.md) — `recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for…
|
||||
- [recurrence-helpers](./src/recurrence/recurrence-helpers.md) — `recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurren…
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar-boundary](./src/calendar/calendar-boundary.md) — `calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and…
|
||||
- [calendar-core](./src/calendar/calendar-core.md) — `calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput`…
|
||||
- [calendar-duration](./src/calendar/calendar-duration.md) — `calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from…
|
||||
- [calendar-grid](./src/calendar/calendar-grid.md) — `calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components…
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-error](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence-generate](./src/recurrence/recurrence-generate.md) — `recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for…
|
||||
- [recurrence-helpers](./src/recurrence/recurrence-helpers.md) — `recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurren…
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar-boundary](./src/calendar/calendar-boundary.md) — `calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and…
|
||||
- [calendar-core](./src/calendar/calendar-core.md) — `calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput`…
|
||||
- [calendar-duration](./src/calendar/calendar-duration.md) — `calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from…
|
||||
- [calendar-grid](./src/calendar/calendar-grid.md) — `calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components…
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-error](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence-generate](./src/recurrence/recurrence-generate.md) — `recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for…
|
||||
- [recurrence-helpers](./src/recurrence/recurrence-helpers.md) — `recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurren…
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar-boundary](./src/calendar/calendar-boundary.md) — `calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and…
|
||||
- [calendar-core](./src/calendar/calendar-core.md) — `calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput`…
|
||||
- [calendar-duration](./src/calendar/calendar-duration.md) — `calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from…
|
||||
- [calendar-grid](./src/calendar/calendar-grid.md) — `calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components…
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-error](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [datetime-otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence-generate](./src/recurrence/recurrence-generate.md) — `recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for…
|
||||
- [recurrence-helpers](./src/recurrence/recurrence-helpers.md) — `recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurren…
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_- [business-days](./src/business-days/business-days.md) — <!-- begin:auto-docs --> _(no submodules)_- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_ - [calendar-boundary](./src/calendar/calendar-boundary.md) — `calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and…
|
||||
- [calendar-core](./src/calendar/calendar-core.md) — `calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput`…
|
||||
- [calendar-duration](./src/calendar/calendar-duration.md) — `calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from…
|
||||
- [calendar-grid](./src/calendar/calendar-grid.md) — `calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components…
|
||||
- [calendar](./src/calendar/calendar.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [datetime-error](./src/errors/datetime-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [datetime-otel](./src/otel/datetime-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_ - [recurrence-generate](./src/recurrence/recurrence-generate.md) — `recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for…
|
||||
- [recurrence-helpers](./src/recurrence/recurrence-helpers.md) — `recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurren…
|
||||
- [recurrence](./src/recurrence/recurrence.md) — <!-- begin:auto-docs --> _(no submodules)_- [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_ - [relative](./src/relative/relative.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [`calendar`](src/calendar/calendar.md) | Core date arithmetic: `toDate`, `addDuration`, `subtractDuration`, `startOf`, `endOf`, `getCalendarMonth`, `daysInMonth`, `isLeapYear`, `isSameDay`, `isSameMonth` |
|
||||
| [`business-days`](src/business-days/business-days.md) | Working-day arithmetic with holiday and custom-weekend support |
|
||||
| [`recurrence`](src/recurrence/recurrence.md) | RRULE parse/format/expand engine (DAILY/WEEKLY/MONTHLY/YEARLY) |
|
||||
| [`relative`](src/relative/relative.md) | Human-readable relative time: `formatRelative`, `timeAgo`, `timeUntil`, `diffInUnits` |
|
||||
| [`errors`](src/errors/datetime-error.md) | `DatetimeError` + `DATETIME_CODES` registry |
|
||||
| [`otel`](src/otel/datetime-otel.md) | OpenTelemetry histogram and counter instrumentation |
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { addDuration, getCalendarMonth, businessDaysBetween, expandRecurrence, parseRRule, formatRelative } from "@suaveplan/datetime";
|
||||
|
||||
// Calendar math
|
||||
const nextMonth = addDuration(new Date(), { months: 1 });
|
||||
const march2024 = getCalendarMonth(2024, 3);
|
||||
|
||||
// Business days
|
||||
const tradingDays = businessDaysBetween("2024-01-01", "2024-04-01");
|
||||
|
||||
// Recurrence
|
||||
const meetings = expandRecurrence(parseRRule("FREQ=WEEKLY;BYDAY=MO,WE;COUNT=8"));
|
||||
|
||||
// Relative time
|
||||
console.log(formatRelative(new Date(Date.now() - 3 * 3600_000))); // "3 hours ago"
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/datetime
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# business-days
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `business-days` module provides a complete suite of working-day arithmetic functions for `@suaveplan/datetime`. It models "business day" as any day that is neither in the caller-supplied `weekendDays` set (default: Sunday and Saturday) nor in the caller-supplied `holidays` list. This allows the same API to serve different jurisdictions and corporate calendars without baking in any cultural assumptions.
|
||||
|
||||
All functions accept the polymorphic `DateInput` type and delegate to `toDate` for coercion, so callers can pass ISO strings, epoch milliseconds, or `Date` objects interchangeably.
|
||||
|
||||
## Features
|
||||
|
||||
- `isBusinessDay` — predicate for whether a given date is a working day
|
||||
- `nextBusinessDay` / `previousBusinessDay` — scan forward/backward for the nearest working day
|
||||
- `addBusinessDays` / `subtractBusinessDays` — advance or retreat by an exact count of working days
|
||||
- `businessDaysBetween` — count working days in a half-open interval `[start, end)`
|
||||
- Configurable `weekendDays` (any subset of 0–6)
|
||||
- Holiday exclusion via an `Date[]` array
|
||||
- Cooperative cancellation via `AbortSignal` (checked every 256 iterations in `businessDaysBetween`)
|
||||
- OTel counter via `recordBusinessDaysBetween`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { isBusinessDay, addBusinessDays } from "@suaveplan/datetime/business-days";
|
||||
|
||||
// Simple weekday check
|
||||
console.log(isBusinessDay("2024-03-18")); // true (Monday)
|
||||
console.log(isBusinessDay("2024-03-16")); // false (Saturday)
|
||||
|
||||
// Advance 5 business days
|
||||
const result = addBusinessDays("2024-03-18", 5);
|
||||
console.log(result.toDateString()); // Fri Mar 29 2024 (skips weekend)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
businessDaysBetween,
|
||||
nextBusinessDay,
|
||||
} from "@suaveplan/datetime/business-days";
|
||||
|
||||
const nyseHolidays = [
|
||||
new Date("2024-01-01"), // New Year's Day
|
||||
new Date("2024-07-04"), // Independence Day
|
||||
];
|
||||
|
||||
// How many NYSE trading days in Q1 2024?
|
||||
const q1 = businessDaysBetween("2024-01-01", "2024-04-01", {
|
||||
holidays: nyseHolidays,
|
||||
});
|
||||
console.log(q1); // ~62 trading days
|
||||
|
||||
// Cooperative cancellation
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 10);
|
||||
const days = businessDaysBetween("2020-01-01", "2030-01-01", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
console.log(days); // partial count, aborted after ~256 iterations
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isBusinessDay(date: DateInput, opts?: BusinessDayOptions): boolean`
|
||||
|
||||
Returns `true` when `date` is not a weekend day and not a holiday.
|
||||
|
||||
- `opts.weekendDays` — default `[0, 6]` (Sun, Sat)
|
||||
- `opts.holidays` — list of `Date` objects to exclude
|
||||
|
||||
### `nextBusinessDay(date: DateInput, opts?: BusinessDayOptions): Date`
|
||||
|
||||
Returns the first business day strictly after `date`.
|
||||
|
||||
### `previousBusinessDay(date: DateInput, opts?: BusinessDayOptions): Date`
|
||||
|
||||
Returns the first business day strictly before `date`.
|
||||
|
||||
### `addBusinessDays(date: DateInput, n: number, opts?: BusinessDayOptions): Date`
|
||||
|
||||
Advance `date` by exactly `|n|` business days. Negative `n` moves backward.
|
||||
|
||||
### `subtractBusinessDays(date: DateInput, n: number, opts?: BusinessDayOptions): Date`
|
||||
|
||||
Move `date` backward by exactly `n` business days. Delegates to `addBusinessDays(date, -n)`.
|
||||
|
||||
### `businessDaysBetween(start: DateInput, end: DateInput, opts?: BusinessDayOptions): number`
|
||||
|
||||
Count business days in `[start, end)`. Returns a negative number when `end < start`. Checks `opts.signal` every 256 iterations.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Holiday lookup uses a `Set<string>` keyed by `"YYYY-M-D"` (local time) built once per call. This is O(1) per day check and avoids allocating `Date` objects for the comparison.
|
||||
|
||||
All date arithmetic is performed at local-midnight epoch millis to prevent time-of-day accumulation errors across DST transitions. The `midnight()` helper normalises any input to 00:00:00.000 local time before iteration begins.
|
||||
|
||||
`addBusinessDays(d, 0)` returns the local midnight of `d` regardless of whether `d` itself is a business day, because zero steps means no movement.
|
||||
@@ -0,0 +1,99 @@
|
||||
# calendar-boundary
|
||||
|
||||
## Purpose
|
||||
|
||||
`calendar-boundary` provides `startOf` and `endOf` functions for `@suaveplan/datetime`, computing the precise first and last moments of any calendar unit (year, month, week, day, hour, minute, second, or millisecond) for a given date. These are the building blocks for date-range queries, fiscal period calculations, and UI calendar display logic.
|
||||
|
||||
Both functions are pure and stateless. They return new `Date` objects without mutating their inputs and operate in local time.
|
||||
|
||||
## Features
|
||||
|
||||
- `startOf` — first moment of the given unit (midnight for day, 00:00:00.000 for hours, etc.)
|
||||
- `endOf` — last millisecond of the given unit (23:59:59.999 for day)
|
||||
- Supports all `DurationUnit` values: `years`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`, `milliseconds`
|
||||
- `weeks` uses Sunday as the week start (matching `Date.prototype.getDay()`)
|
||||
- `BoundaryOptions` interface with optional `clock` for future extension
|
||||
- `milliseconds` unit returns the date unchanged (identity)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { startOf, endOf } from "@suaveplan/datetime";
|
||||
|
||||
const d = new Date(2024, 2, 15, 14, 30, 45, 500); // Mar 15 2024 14:30:45.500
|
||||
|
||||
const dayStart = startOf(d, "days");
|
||||
// 2024-03-15 00:00:00.000
|
||||
|
||||
const monthEnd = endOf(d, "months");
|
||||
// 2024-03-31 23:59:59.999
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { startOf, endOf } from "@suaveplan/datetime";
|
||||
|
||||
// Fiscal year boundaries
|
||||
const q1Start = startOf("2024-03-15", "years"); // Jan 1 2024
|
||||
const q1End = endOf("2024-03-31", "months"); // Mar 31 2024 23:59:59.999
|
||||
|
||||
// Current week range (Sunday–Saturday)
|
||||
const today = new Date();
|
||||
const weekStart = startOf(today, "weeks");
|
||||
const weekEnd = endOf(today, "weeks");
|
||||
|
||||
// Hour slot boundaries
|
||||
const slotStart = startOf(new Date(2024, 0, 1, 9, 45, 0), "hours");
|
||||
// 2024-01-01 09:00:00.000
|
||||
const slotEnd = endOf(new Date(2024, 0, 1, 9, 45, 0), "hours");
|
||||
// 2024-01-01 09:59:59.999
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `BoundaryOptions`
|
||||
|
||||
```ts
|
||||
interface BoundaryOptions {
|
||||
clock?: Clock;
|
||||
}
|
||||
```
|
||||
|
||||
Reserved for future use (e.g. timezone-aware boundaries). Currently unused in the function bodies.
|
||||
|
||||
### `startOf(date: DateInput, unit: DurationUnit, opts?: BoundaryOptions): Date`
|
||||
|
||||
Returns the first moment of the `unit` period containing `date`.
|
||||
|
||||
| Unit | Result |
|
||||
|------|--------|
|
||||
| `years` | Jan 1 00:00:00.000 |
|
||||
| `months` | 1st of month 00:00:00.000 |
|
||||
| `weeks` | Sunday of the week 00:00:00.000 |
|
||||
| `days` | midnight 00:00:00.000 |
|
||||
| `hours` | :00:00.000 of the hour |
|
||||
| `minutes` | :00.000 of the minute |
|
||||
| `seconds` | .000 of the second |
|
||||
| `milliseconds` | unchanged |
|
||||
|
||||
### `endOf(date: DateInput, unit: DurationUnit, opts?: BoundaryOptions): Date`
|
||||
|
||||
Returns the last millisecond of the `unit` period containing `date`.
|
||||
|
||||
| Unit | Result |
|
||||
|------|--------|
|
||||
| `years` | Dec 31 23:59:59.999 |
|
||||
| `months` | Last day 23:59:59.999 |
|
||||
| `weeks` | Saturday of the week 23:59:59.999 |
|
||||
| `days` | 23:59:59.999 |
|
||||
| `hours` | :59:59.999 |
|
||||
| `minutes` | :59.999 |
|
||||
| `seconds` | .999 |
|
||||
| `milliseconds` | unchanged |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`startOf("weeks")` subtracts `date.getDay()` days (0 = Sunday) to find the Sunday anchor. `endOf("weeks")` adds `6 - date.getDay()` to reach Saturday. This matches the JavaScript `Date.getDay()` convention of Sunday = 0. Callers needing Monday-start weeks should offset by one day using `addDuration`.
|
||||
|
||||
`endOf("months")` calls `daysInMonth` to find the last valid day of the month, correctly handling February on leap years.
|
||||
@@ -0,0 +1,82 @@
|
||||
# calendar-core
|
||||
|
||||
## Purpose
|
||||
|
||||
`calendar-core` provides the foundational date primitives for `@suaveplan/datetime`: coercing heterogeneous `DateInput` values into `Date` objects, predicates for same-day and same-month equality, leap year detection, and days-in-month calculation. These are the lowest-level building blocks consumed by every other calendar sub-module.
|
||||
|
||||
All functions are pure and stateless. They accept local-time values and return local-time `Date` objects. There are no side effects and no external dependencies beyond `@suaveplan/error`.
|
||||
|
||||
## Features
|
||||
|
||||
- `toDate` — coerce `Date | string | number` to `Date`, throws `DatetimeError` for invalid inputs
|
||||
- `asTimezone` — brands a raw string as the opaque `Timezone` type
|
||||
- `isLeapYear` — true for proleptic Gregorian leap years
|
||||
- `daysInMonth` — correct day-count for any month, including February on leap years
|
||||
- `isSameDay` — calendar equality predicate (year + month + date)
|
||||
- `isSameMonth` — calendar month equality predicate (year + month)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { toDate, isLeapYear, daysInMonth, isSameDay } from "@suaveplan/datetime";
|
||||
|
||||
const d = toDate("2024-02-15");
|
||||
console.log(isLeapYear(2024)); // true
|
||||
console.log(daysInMonth(2024, 2)); // 29
|
||||
console.log(isSameDay(d, new Date(2024, 1, 15))); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { toDate, isSameMonth, asTimezone } from "@suaveplan/datetime";
|
||||
|
||||
const jan1 = toDate(new Date(2024, 0, 1));
|
||||
const jan31 = toDate("2024-01-31");
|
||||
const feb1 = toDate(1706745600000); // epoch ms for 2024-02-01
|
||||
|
||||
console.log(isSameMonth(jan1, jan31)); // true
|
||||
console.log(isSameMonth(jan31, feb1)); // false
|
||||
|
||||
// Error on invalid input
|
||||
try {
|
||||
toDate("not-a-date");
|
||||
} catch (e) {
|
||||
console.log((e as { code: string }).code); // DATETIME.PARSE.INVALID_INPUT
|
||||
}
|
||||
|
||||
const tz = asTimezone("America/New_York");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `toDate(input: DateInput): Date`
|
||||
|
||||
Coerce `input` to a `Date`. Accepts `Date`, ISO string, or epoch millis.
|
||||
Throws `DatetimeError("DATETIME.PARSE.INVALID_INPUT")` for unparseable strings or `NaN` inputs.
|
||||
|
||||
### `asTimezone(tz: string): Timezone`
|
||||
|
||||
Cast `tz` to the branded `Timezone` type. No runtime validation is performed.
|
||||
|
||||
### `isLeapYear(year: number): boolean`
|
||||
|
||||
Returns `true` for proleptic Gregorian leap years (divisible by 4, not 100, or divisible by 400).
|
||||
|
||||
### `daysInMonth(year: number, month: number): number`
|
||||
|
||||
Returns the number of days in the given month (1-indexed). Handles February leap-year logic via `new Date(year, month, 0).getDate()`.
|
||||
|
||||
### `isSameDay(a: DateInput, b: DateInput): boolean`
|
||||
|
||||
Returns `true` when `a` and `b` share the same calendar year, month, and date.
|
||||
|
||||
### `isSameMonth(a: DateInput, b: DateInput): boolean`
|
||||
|
||||
Returns `true` when `a` and `b` share the same calendar year and month.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`daysInMonth` uses the "day 0 of the following month" trick: `new Date(year, month, 0).getDate()` returns the last day of the prior month, which equals the number of days in that month. This avoids a lookup table and handles leap years correctly via the JavaScript `Date` constructor.
|
||||
|
||||
`toDate` copies `Date` inputs (returns `new Date(input.getTime())`) to prevent mutation aliasing. String inputs are passed directly to `new Date()` which follows ISO 8601 parsing rules in all modern runtimes.
|
||||
@@ -0,0 +1,73 @@
|
||||
# calendar-duration
|
||||
|
||||
## Purpose
|
||||
|
||||
`calendar-duration` provides duration arithmetic for `@suaveplan/datetime`: adding and subtracting `Duration` bags from dates with correct month-end clamping and overflow normalisation. It is the arithmetic engine that powers use cases such as "one month from January 31" and "subtract 90 days".
|
||||
|
||||
The module separates year/month arithmetic (done in calendar coordinates to avoid day-count ambiguity) from sub-day arithmetic (done as millisecond offsets on a local-midnight base date).
|
||||
|
||||
## Features
|
||||
|
||||
- `addDuration` — add a `Duration` bag to a date with month-end clamping
|
||||
- `subtractDuration` — subtract a `Duration` bag (negates fields, delegates to `addDuration`)
|
||||
- Handles all `DurationUnit` fields: `years`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`, `milliseconds`
|
||||
- Month overflow/underflow normalisation (e.g. month 13 → January of next year)
|
||||
- Day clamping prevents invalid dates (e.g. Jan 31 + 1 month = Feb 28/29)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { addDuration, subtractDuration } from "@suaveplan/datetime";
|
||||
|
||||
const jan31 = new Date(2024, 0, 31);
|
||||
const feb29 = addDuration(jan31, { months: 1 });
|
||||
console.log(feb29.toDateString()); // Thu Feb 29 2024 (leap year)
|
||||
|
||||
const yesterday = subtractDuration(new Date(), { days: 1 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { addDuration } from "@suaveplan/datetime";
|
||||
|
||||
// Combined duration: 1 year, 2 months, 3 days, 4 hours
|
||||
const base = new Date(2023, 10, 30, 12, 0, 0, 0); // Nov 30 2023 12:00
|
||||
const result = addDuration(base, {
|
||||
years: 1,
|
||||
months: 2,
|
||||
days: 3,
|
||||
hours: 4,
|
||||
});
|
||||
// Feb 2, 2025 16:00 — Nov 30 + 1y2m = Jan 30 2025, then +3d = Feb 2, +4h = 16:00
|
||||
|
||||
// Month underflow normalisation: subtracting months past January
|
||||
const dec1 = new Date(2024, 11, 1);
|
||||
const oct = addDuration(dec1, { months: -2 });
|
||||
console.log(oct.getMonth()); // 9 (October)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `addDuration(date: DateInput, duration: Duration): Date`
|
||||
|
||||
Add `duration` to `date`. Year/month fields are applied first with overflow/underflow normalisation. Day/week/hour/minute/second/millisecond fields are added as a millisecond offset to the clamped local-midnight date.
|
||||
|
||||
**Params:**
|
||||
|
||||
- `date` — `Date`, ISO string, or epoch number
|
||||
- `duration` — `Duration` bag; all fields are optional and default to `0`
|
||||
|
||||
**Returns:** New `Date` (does not mutate input).
|
||||
|
||||
### `subtractDuration(date: DateInput, duration: Duration): Date`
|
||||
|
||||
Subtract `duration` from `date`. Internally negates all fields and delegates to `addDuration`.
|
||||
|
||||
**Params:** same as `addDuration`.
|
||||
|
||||
**Returns:** New `Date`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Year and month arithmetic is performed in integer calendar coordinates rather than millisecond arithmetic to avoid the 28–31 day ambiguity. After clamping the day to the last valid day of the resulting month, the remaining duration components (weeks, days, hours, minutes, seconds, milliseconds) are totalled as a single millisecond value and added to a local-midnight `Date` object. This approach avoids DST-induced errors that would occur if each component were added sequentially in timestamp space.
|
||||
@@ -0,0 +1,78 @@
|
||||
# calendar-grid
|
||||
|
||||
## Purpose
|
||||
|
||||
`calendar-grid` provides `getCalendarMonth`, which builds a full calendar month grid for use in UI calendar components and scheduling displays. It returns a `CalendarMonth` value containing one `CalendarDay` entry per day in the month, each annotated with its day-of-week index, whether it is a weekend, and whether it represents today (driven by an injectable `Clock`).
|
||||
|
||||
The module is intentionally narrow: it answers "what are the days in this month and which one is today?" — layout concerns (week rows, leading/trailing days from adjacent months) are left to the consumer.
|
||||
|
||||
## Features
|
||||
|
||||
- `getCalendarMonth` — builds a `CalendarMonth` grid for a given year and 1-indexed month
|
||||
- Each `CalendarDay` includes `date`, `dayOfWeek` (0=Sun … 6=Sat), `isWeekend`, and `isToday`
|
||||
- Injectable `Clock` for deterministic rendering in tests and server-side snapshots
|
||||
- Zero side effects; all computation is local
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getCalendarMonth } from "@suaveplan/datetime";
|
||||
|
||||
const march = getCalendarMonth(2024, 3);
|
||||
console.log(march.days.length); // 31
|
||||
console.log(march.days[0]?.dayOfWeek); // 5 (Friday — Mar 1 2024)
|
||||
const weekends = march.days.filter((d) => d.isWeekend);
|
||||
console.log(weekends.length); // 10
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { getCalendarMonth } from "@suaveplan/datetime";
|
||||
|
||||
// Deterministic "today" for SSR or snapshot tests
|
||||
const fakeClock = () => new Date(2024, 2, 15).getTime(); // Mar 15 2024
|
||||
const march = getCalendarMonth(2024, 3, { clock: fakeClock });
|
||||
|
||||
const today = march.days.find((d) => d.isToday);
|
||||
console.log(today?.date.getDate()); // 15
|
||||
|
||||
// Render a simple ASCII calendar header
|
||||
for (const day of march.days) {
|
||||
const marker = day.isToday ? "*" : day.isWeekend ? "W" : " ";
|
||||
process.stdout.write(`${marker}${String(day.date.getDate()).padStart(2)} `);
|
||||
if (day.dayOfWeek === 6) process.stdout.write("\n");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CalendarMonthOptions`
|
||||
|
||||
```ts
|
||||
interface CalendarMonthOptions {
|
||||
clock?: Clock;
|
||||
}
|
||||
```
|
||||
|
||||
- `clock` — injectable `Clock` (returns epoch ms). Defaults to `Date.now`.
|
||||
|
||||
### `getCalendarMonth(year: number, month: number, opts?: CalendarMonthOptions): CalendarMonth`
|
||||
|
||||
Build a `CalendarMonth` for the given `year` and 1-indexed `month`.
|
||||
|
||||
**Params:**
|
||||
|
||||
- `year` — full calendar year (e.g. 2024)
|
||||
- `month` — 1-indexed month (1 = January, 12 = December)
|
||||
- `opts.clock` — optional injectable clock
|
||||
|
||||
**Returns:** `{ year, month, days: CalendarDay[] }` where each `CalendarDay` is:
|
||||
|
||||
```ts
|
||||
{ date: Date; dayOfWeek: Weekday; isWeekend: boolean; isToday: boolean }
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isToday` is computed by comparing a `"year-month-date"` string derived from `clock()` to the same string for each grid day. This string comparison avoids UTC/local timezone confusion that would arise from comparing epoch timestamps directly. The `clock` default (`Date.now`) is captured once per call to ensure consistent "today" across all days in the grid.
|
||||
112
docs/foundation/timing/datetime/src/calendar/calendar.md
Normal file
112
docs/foundation/timing/datetime/src/calendar/calendar.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# calendar
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `calendar` module is the arithmetic core of `@suaveplan/datetime`. It provides the fundamental building blocks for working with dates: coercing heterogeneous `DateInput` values into `Date` objects, adding and subtracting `Duration` bags with correct month-end clamping, computing start-of / end-of boundaries for any time unit, and generating full calendar-month grids with today-awareness injected via a pluggable `Clock`.
|
||||
|
||||
All functions are pure and stateless. They accept local-time `Date` objects and return local-time `Date` objects. Timezone conversion (IANA) is not in scope for this module — it belongs to consumer code that formats dates for display.
|
||||
|
||||
## Features
|
||||
|
||||
- `toDate` — coerce `Date | string | number` to `Date`, throws `DatetimeError` for invalid inputs
|
||||
- `isLeapYear` — true for proleptic Gregorian leap years
|
||||
- `daysInMonth` — correct day-count for any month including February on leap years
|
||||
- `isSameDay` / `isSameMonth` — calendar equality predicates
|
||||
- `addDuration` / `subtractDuration` — month-clamping arithmetic (Jan 31 + 1 month = Feb 28/29)
|
||||
- `startOf` / `endOf` — precise boundary computation for years, months, weeks, days, hours, minutes, seconds, milliseconds
|
||||
- `getCalendarMonth` — full grid of `CalendarDay` entries with `isToday` driven by injected `Clock`
|
||||
- `asTimezone` — brands a raw string as a `Timezone` opaque type
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { toDate, addDuration, isSameDay } from "@suaveplan/datetime/calendar";
|
||||
|
||||
const jan31 = toDate("2024-01-31");
|
||||
const feb29 = addDuration(jan31, { months: 1 });
|
||||
console.log(feb29.toISOString()); // 2024-02-29T00:00:00.000Z (leap year)
|
||||
|
||||
console.log(isSameDay(feb29, toDate("2024-02-29"))); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getCalendarMonth,
|
||||
startOf,
|
||||
endOf,
|
||||
subtractDuration,
|
||||
} from "@suaveplan/datetime/calendar";
|
||||
|
||||
// Inject a fake clock for deterministic "isToday" rendering in tests
|
||||
const fakeClock = () => new Date("2024-03-15T12:00:00Z").getTime();
|
||||
|
||||
const march = getCalendarMonth(2024, 3, { clock: fakeClock });
|
||||
const today = march.days.find((d) => d.isToday);
|
||||
console.log(today?.date.getDate()); // 15
|
||||
|
||||
// Fiscal quarter boundaries
|
||||
const q1Start = startOf("2024-03-15", "years"); // 2024-01-01
|
||||
const q1End = endOf("2024-03-31", "months"); // 2024-03-31T23:59:59.999
|
||||
|
||||
// Rolling 90-day window
|
||||
const ninetyDaysAgo = subtractDuration(new Date(), { days: 90 });
|
||||
console.log(ninetyDaysAgo.toISOString());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `toDate(input: DateInput): Date`
|
||||
|
||||
Coerce `input` to a `Date`. Accepts `Date`, ISO string, or epoch millis.
|
||||
Throws `DatetimeError("DATETIME.PARSE.INVALID_INPUT")` for unparseable strings or `NaN` inputs.
|
||||
|
||||
### `asTimezone(tz: string): Timezone`
|
||||
|
||||
Cast `tz` to the branded `Timezone` type. No runtime validation.
|
||||
|
||||
### `isLeapYear(year: number): boolean`
|
||||
|
||||
Returns `true` for proleptic Gregorian leap years.
|
||||
|
||||
### `daysInMonth(year: number, month: number): number`
|
||||
|
||||
Returns the number of days in the given month (1-indexed). Handles February leap-year logic.
|
||||
|
||||
### `isSameDay(a: DateInput, b: DateInput): boolean`
|
||||
|
||||
Returns `true` when `a` and `b` share the same calendar year, month, and day.
|
||||
|
||||
### `isSameMonth(a: DateInput, b: DateInput): boolean`
|
||||
|
||||
Returns `true` when `a` and `b` share the same calendar year and month.
|
||||
|
||||
### `addDuration(date: DateInput, duration: Duration): Date`
|
||||
|
||||
Add `duration` to `date`. Year/month fields are applied first with overflow normalisation. Day/week/hour/minute/second/millisecond fields are added as millisecond offsets. Month-end clamping prevents invalid dates (e.g. March 31 - 1 month = Feb 28/29).
|
||||
|
||||
### `subtractDuration(date: DateInput, duration: Duration): Date`
|
||||
|
||||
Subtract `duration` from `date`. Internally negates all fields and delegates to `addDuration`.
|
||||
|
||||
### `startOf(date: DateInput, unit: DurationUnit, opts?: BoundaryOptions): Date`
|
||||
|
||||
Returns the first moment of the given `unit` period. For `weeks`, Sunday is the start of the week.
|
||||
|
||||
### `endOf(date: DateInput, unit: DurationUnit, opts?: BoundaryOptions): Date`
|
||||
|
||||
Returns the last millisecond of the given `unit` period.
|
||||
|
||||
### `getCalendarMonth(year: number, month: number, opts?: CalendarMonthOptions): CalendarMonth`
|
||||
|
||||
Build a `CalendarMonth` grid. `opts.clock` defaults to `Date.now`. Each `CalendarDay` includes `date`, `dayOfWeek`, `isWeekend`, and `isToday`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Month arithmetic is performed in calendar coordinates (year/month/day integers) rather than millisecond arithmetic to avoid the 28–31 day ambiguity. Day clamping uses `daysInMonth` to find the last valid day after year/month adjustment, then the remaining sub-day duration components are added as a single millisecond offset to the clamped local-midnight date.
|
||||
|
||||
`startOf("weeks")` uses Sunday as the week start (index 0) to match `Date.prototype.getDay()`. Callers that need Monday-start weeks should offset the result by one day.
|
||||
91
docs/foundation/timing/datetime/src/errors/datetime-error.md
Normal file
91
docs/foundation/timing/datetime/src/errors/datetime-error.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# datetime-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DatetimeError` is the typed error class for the entire `@suaveplan/datetime` package. It extends the project-wide `SuaveplanError` base class with a narrow `DatetimeCode` type parameter, allowing callers to discriminate error origins with `instanceof` and narrow the `code` property to one of three categories: `PARSE.INVALID_INPUT`, `RECURRENCE.INFINITE_EXPANSION`, and `TIMEZONE.UNKNOWN`.
|
||||
|
||||
Keeping all error definitions in a single, versioned registry (`DATETIME_CODES`) means consumers can exhaustively handle failure modes without consulting documentation or source code.
|
||||
|
||||
## Features
|
||||
|
||||
- Inherits full `SuaveplanError` surface: `code`, `context`, `timestamp`, `traceId`, `cause`
|
||||
- Three error codes covering the library's failure surface
|
||||
- `defineCodes` ensures the registry is frozen and type-inferred at build time
|
||||
- Compatible with `walkCauses`, `toSpanEvent`, and the `@suaveplan/error` wire format
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DatetimeError, DATETIME_CODES } from "@suaveplan/datetime/errors";
|
||||
|
||||
throw new DatetimeError(
|
||||
DATETIME_CODES["PARSE.INVALID_INPUT"],
|
||||
"datetime.parse.invalid_input",
|
||||
{ context: { input: "not-a-date" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DatetimeError, DATETIME_CODES } from "@suaveplan/datetime/errors";
|
||||
|
||||
function guardExpansion(hasTerminator: boolean): void {
|
||||
if (!hasTerminator) {
|
||||
throw new DatetimeError(
|
||||
DATETIME_CODES["RECURRENCE.INFINITE_EXPANSION"],
|
||||
"datetime.recurrence.infinite_expansion",
|
||||
{
|
||||
context: { hint: "supply count or until" },
|
||||
traceId: "abc-123",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
guardExpansion(false);
|
||||
} catch (err) {
|
||||
if (err instanceof DatetimeError) {
|
||||
// code is narrowed to DatetimeCode
|
||||
console.error(err.code, err.context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DATETIME_CODES`
|
||||
|
||||
`Record<"PARSE.INVALID_INPUT" | "RECURRENCE.INFINITE_EXPANSION" | "TIMEZONE.UNKNOWN", string>`
|
||||
|
||||
Frozen registry of all datetime error codes. Values are fully-qualified strings of the form `DATETIME.<reason>`.
|
||||
|
||||
### `DatetimeCode`
|
||||
|
||||
`type DatetimeCode = (typeof DATETIME_CODES)[keyof typeof DATETIME_CODES]`
|
||||
|
||||
Union of all valid datetime error code strings.
|
||||
|
||||
### `class DatetimeError extends SuaveplanError<DatetimeCode>`
|
||||
|
||||
**Constructor:** `new DatetimeError(code: DatetimeCode, messageKey: string, options?: SuaveplanErrorOptions)`
|
||||
|
||||
- `code` — one of the values in `DATETIME_CODES`
|
||||
- `messageKey` — i18n key passed to `resolveMessage`
|
||||
- `options` — optional `{ cause, context, clock, traceId }`
|
||||
|
||||
**Inherited properties:** `code`, `context`, `timestamp`, `traceId`, `cause`, `name`, `stack`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The three-code surface was chosen to be exhaustive yet minimal:
|
||||
|
||||
- Parsing errors cover any `DateInput` coercion failure
|
||||
- Recurrence expansion errors guard against infinite loops
|
||||
- Timezone errors are reserved for future IANA name validation
|
||||
|
||||
The `defineCodes` helper freezes the registry object, preventing accidental mutation and enabling tree-shaking of unused codes.
|
||||
69
docs/foundation/timing/datetime/src/otel/datetime-otel.md
Normal file
69
docs/foundation/timing/datetime/src/otel/datetime-otel.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# datetime-otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`datetime-otel` provides lightweight OpenTelemetry instrumentation for the `@suaveplan/datetime` package. It registers a histogram for `expandRecurrence` call durations and a counter for `businessDaysBetween` interval evaluations. Both instruments are initialised lazily via `metrics.getMeter()`, which is a no-op when no global `MeterProvider` is configured, ensuring zero overhead in environments without observability infrastructure.
|
||||
|
||||
The module exposes thin wrapper functions (`recordExpandRecurrence`, `recordBusinessDaysBetween`) so the hot-path modules do not need to import `@opentelemetry/api` directly.
|
||||
|
||||
## Features
|
||||
|
||||
- `datetime.expand_recurrence.duration_ms` histogram (unit: `ms`)
|
||||
- `datetime.business_days_between.total` counter (unit: `{days}`)
|
||||
- Zero-overhead when no OTel SDK is configured
|
||||
- Named meter `@suaveplan/datetime` for easy filtering in dashboards
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordExpandRecurrence } from "@suaveplan/datetime/otel";
|
||||
|
||||
const start = Date.now();
|
||||
// ... run expandRecurrence ...
|
||||
recordExpandRecurrence(Date.now() - start);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { recordBusinessDaysBetween } from "@suaveplan/datetime/otel";
|
||||
|
||||
// Wire up an SDK meter provider before the first import of datetime modules
|
||||
const provider = new MeterProvider();
|
||||
// metrics.setGlobalMeterProvider(provider); // done at app startup
|
||||
|
||||
// Later, inside businessDaysBetween implementation:
|
||||
recordBusinessDaysBetween(365); // records 365 day-intervals evaluated
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `expandRecurrenceDuration`
|
||||
|
||||
`Histogram` — OpenTelemetry histogram for `expandRecurrence` durations.
|
||||
|
||||
### `businessDaysBetweenTotal`
|
||||
|
||||
`Counter` — OpenTelemetry counter for `businessDaysBetween` day-intervals.
|
||||
|
||||
### `recordExpandRecurrence(durationMs: number): void`
|
||||
|
||||
Records a single `expandRecurrence` call duration. Call immediately after the expansion completes.
|
||||
|
||||
- `durationMs` — wall-clock elapsed time in milliseconds
|
||||
|
||||
### `recordBusinessDaysBetween(count: number): void`
|
||||
|
||||
Increments the `businessDaysBetween` counter by the number of calendar days evaluated.
|
||||
|
||||
- `count` — number of calendar-day intervals walked during the call
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`metrics.getMeter()` is called once at module initialisation. The returned `Meter` instance is cached for the lifetime of the process. In environments without a registered `MeterProvider`, all record/add calls are no-ops at the OTel SDK level, incurring only a function-call overhead.
|
||||
|
||||
The histogram uses millisecond granularity, which is sufficient for typical recurrence expansions (tens to hundreds of milliseconds). For sub-millisecond benchmarks, use the `mitata` benchmark suite instead.
|
||||
@@ -0,0 +1,95 @@
|
||||
# recurrence-generate
|
||||
|
||||
## Purpose
|
||||
|
||||
`recurrence-generate` provides the internal generator functions that produce infinite sequences of candidate dates for each recurrence frequency supported by `@suaveplan/datetime`. Each generator yields dates strictly after a given `from` anchor, and the `generateCandidates` dispatcher routes to the correct generator based on `RecurrenceRule.freq`.
|
||||
|
||||
This module is an internal implementation detail. Public callers should use `expandRecurrence` and `nextOccurrence` from `recurrence.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateDaily` — daily step with optional `BYDAY` weekday filter
|
||||
- `generateWeekly` — weekly step, either plain (same weekday) or with `BYDAY` multi-day selection
|
||||
- `generateMonthly` — monthly step supporting `BYMONTHDAY`, `BYDAY`, or plain monthly recurrence
|
||||
- `generateYearly` — yearly step supporting `BYMONTHDAY`, `BYDAY`, or plain yearly recurrence
|
||||
- `generateCandidates` — dispatcher that routes to the correct generator from `RecurrenceRule.freq`
|
||||
- All generators are infinite; termination is enforced by callers (`count` / `until` / `AbortSignal`)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generateCandidates } from "./recurrence-generate.js";
|
||||
|
||||
const rule = { freq: "daily" as const, count: 5 };
|
||||
const from = new Date(2024, 0, 1);
|
||||
|
||||
const gen = generateCandidates(rule, from);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
console.log(gen.next().value?.toDateString());
|
||||
}
|
||||
// Tue Jan 02 2024 … Sat Jan 06 2024
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { generateCandidates } from "./recurrence-generate.js";
|
||||
|
||||
// Monthly with BYMONTHDAY — yields the 15th of each month
|
||||
const rule = {
|
||||
freq: "monthly" as const,
|
||||
byMonthDay: [15],
|
||||
};
|
||||
const from = new Date(2024, 0, 1);
|
||||
const gen = generateCandidates(rule, from);
|
||||
|
||||
const first = gen.next().value!;
|
||||
console.log(first.toDateString()); // Mon Jan 15 2024
|
||||
|
||||
const second = gen.next().value!;
|
||||
console.log(second.toDateString()); // Thu Feb 15 2024
|
||||
|
||||
// Weekly with BYDAY — yields Mon/Wed/Fri
|
||||
const weeklyRule = {
|
||||
freq: "weekly" as const,
|
||||
byDay: [1, 3, 5] as const,
|
||||
};
|
||||
const weeklyGen = generateCandidates(weeklyRule, new Date(2024, 2, 11));
|
||||
console.log(weeklyGen.next().value?.getDay()); // 3 (Wed Mar 13)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generateCandidates(rule: RecurrenceRule, from: Date): Generator<Date>`
|
||||
|
||||
Unified dispatcher. Routes to the frequency-specific generator based on `rule.freq`. All yielded dates are strictly after `from`.
|
||||
|
||||
### `generateDaily(rule, from, interval): Generator<Date>`
|
||||
|
||||
Steps `interval` days at a time. If `rule.byDay` is set, only yields dates whose `getDay()` is in the list.
|
||||
|
||||
### `generateWeekly(rule, from, interval): Generator<Date>`
|
||||
|
||||
If `rule.byDay` is set: anchors to the Sunday of the current week, then emits the specified weekdays in sorted order each `interval * 7` days. Without `byDay`: yields the same weekday every `interval * 7` days.
|
||||
|
||||
### `generateMonthly(rule, from, interval): Generator<Date>`
|
||||
|
||||
Three modes:
|
||||
|
||||
1. `byMonthDay` — emits the specified day-of-month values (sorted) in each month, skipping months where a day exceeds `daysInMonth`.
|
||||
2. `byDay` — scans each month and emits all days matching the weekday list.
|
||||
3. Plain — yields the same day-of-month each `interval` months using `addMonths` clamping.
|
||||
|
||||
### `generateYearly(rule, from, interval): Generator<Date>`
|
||||
|
||||
Three modes:
|
||||
|
||||
1. `byMonthDay` — emits specified day-of-month values in each year.
|
||||
2. `byDay` — advances one day at a time from the year anchor, yielding matching weekdays.
|
||||
3. Plain — yields the same date each `interval` years using `addYears`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All generators are implemented as native ES `Generator` functions (`function*`) to enable lazy evaluation. They produce an infinite sequence; the caller (`expandRecurrence`, `nextOccurrence`) is responsible for applying `count`, `until`, and `AbortSignal` termination conditions.
|
||||
|
||||
The `generateWeekly` with `BYDAY` mode anchors to the start of the week (Sunday) on the first iteration, which means it may emit dates in the current week before advancing. Dates before or equal to `from` are filtered out with a `candidate > from` guard.
|
||||
@@ -0,0 +1,98 @@
|
||||
# recurrence-helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
`recurrence-helpers` provides the internal constants and low-level date arithmetic utilities shared across the recurrence sub-modules of `@suaveplan/datetime`. It contains the frequency/day-name mappings used by the RRULE parser and formatter, and the `addDays`, `addMonths`, `addYears`, `daysInMonth`, `toRRuleDate`, and `fromRRuleDate` helpers used by the candidate generators.
|
||||
|
||||
This module is an internal implementation detail. All public API is exposed through `recurrence.ts`. Direct imports are supported but not part of the documented public surface.
|
||||
|
||||
## Features
|
||||
|
||||
- `FREQ_MAP` — maps RRULE `FREQ` strings to `Frequency` values
|
||||
- `FREQ_RRULE` — maps `Frequency` values back to RRULE strings
|
||||
- `DAY_NAME` / `DAY_INDEX` — bidirectional weekday ↔ abbreviation maps
|
||||
- `toRRuleDate` — format a `Date` as `YYYYMMDD`
|
||||
- `fromRRuleDate` — parse `YYYYMMDD` (or `YYYYMMDDTHHmmssZ`) into a `Date`
|
||||
- `daysInMonth` — days in a 1-indexed month (local copy for recurrence module independence)
|
||||
- `addDays` / `addMonths` / `addYears` — safe date arithmetic with month-end clamping
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { toRRuleDate, fromRRuleDate, addDays } from "./recurrence-helpers.js";
|
||||
|
||||
const d = new Date(2024, 0, 31);
|
||||
console.log(toRRuleDate(d)); // "20240131"
|
||||
|
||||
const parsed = fromRRuleDate("20241231");
|
||||
console.log(parsed.getMonth()); // 11 (December)
|
||||
|
||||
const next = addDays(d, 7);
|
||||
console.log(next.getDate()); // 7 (Feb 7 2024)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { addMonths, addYears, daysInMonth } from "./recurrence-helpers.js";
|
||||
|
||||
// Month-end clamping: Jan 31 + 1 month = Feb 29 (leap year 2024)
|
||||
const jan31 = new Date(2024, 0, 31);
|
||||
const feb = addMonths(jan31, 1);
|
||||
console.log(feb.getDate()); // 29
|
||||
|
||||
// Year addition uses addMonths internally
|
||||
const twoYearsLater = addYears(jan31, 2);
|
||||
console.log(twoYearsLater.getFullYear()); // 2026
|
||||
|
||||
// Days in February 2024 (leap year)
|
||||
console.log(daysInMonth(2024, 2)); // 29
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FREQ_MAP: Record<string, Frequency>`
|
||||
|
||||
Maps uppercase RRULE frequency strings (`"DAILY"`, `"WEEKLY"`, `"MONTHLY"`, `"YEARLY"`) to their `Frequency` type values.
|
||||
|
||||
### `FREQ_RRULE: Record<Frequency, string>`
|
||||
|
||||
Inverse of `FREQ_MAP`. Maps `Frequency` type values back to uppercase RRULE strings.
|
||||
|
||||
### `DAY_NAME: Record<Weekday, string>`
|
||||
|
||||
Maps weekday indices (0–6) to two-letter RRULE abbreviations (`"SU"` … `"SA"`).
|
||||
|
||||
### `DAY_INDEX: Record<string, Weekday>`
|
||||
|
||||
Maps RRULE day abbreviations to weekday indices.
|
||||
|
||||
### `toRRuleDate(d: Date): string`
|
||||
|
||||
Format `d` as `YYYYMMDD` with zero-padded year, month, and day.
|
||||
|
||||
### `fromRRuleDate(s: string): Date`
|
||||
|
||||
Parse `s` (either `YYYYMMDD` or `YYYYMMDDTHHmmssZ`) into a local-midnight `Date`. The time and timezone suffix are stripped.
|
||||
|
||||
### `daysInMonth(year: number, month: number): number`
|
||||
|
||||
Returns the number of days in the given 1-indexed month. Mirrors the implementation in `calendar-core` to keep this module self-contained.
|
||||
|
||||
### `addDays(d: Date, n: number): Date`
|
||||
|
||||
Add `n` days to `d` using millisecond arithmetic.
|
||||
|
||||
### `addMonths(d: Date, n: number): Date`
|
||||
|
||||
Add `n` months with day clamping to the last valid day of the resulting month.
|
||||
|
||||
### `addYears(d: Date, n: number): Date`
|
||||
|
||||
Add `n` years by delegating to `addMonths(d, n * 12)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`addMonths` is the source of truth for month-end clamping in the recurrence engine. It handles month overflow (month > 12 rolls the year forward) and clamps the day to `daysInMonth(year, month)` using `Math.min`, ensuring that e.g. Jan 31 + 1 month → Feb 28/29 rather than Mar 2/3.
|
||||
|
||||
`daysInMonth` is intentionally duplicated from `calendar-core` rather than imported, to keep `recurrence-helpers` self-contained and free of cross-category imports.
|
||||
102
docs/foundation/timing/datetime/src/recurrence/recurrence.md
Normal file
102
docs/foundation/timing/datetime/src/recurrence/recurrence.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# recurrence
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `recurrence` module implements a simplified iCalendar-compatible recurrence rule engine for `@suaveplan/datetime`. It provides parsing, formatting, and expansion of RRULE-style recurrence rules covering the most common scheduling patterns: daily, weekly, monthly, and yearly recurrences with interval, count, until, by-day-of-week, and by-day-of-month filters.
|
||||
|
||||
The design prioritises correctness and safety over exhaustiveness: it deliberately excludes the more exotic RFC 5545 features (BYSETPOS, BYHOUR, EXDATE, etc.) in order to maintain a clear, auditable code path that cannot produce runaway expansions.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseRRule` — parse RRULE string into a typed `RecurrenceRule`
|
||||
- `formatRRule` — serialise `RecurrenceRule` back to RRULE string (round-trippable)
|
||||
- `nextOccurrence` — compute the next single occurrence after a given date
|
||||
- `expandRecurrence` — expand all occurrences with `count` or `until` bound (required)
|
||||
- Cooperative cancellation via `AbortSignal` (checked every 64 occurrences)
|
||||
- OTel histogram integration via `recordExpandRecurrence`
|
||||
- `clock` injection for deterministic tests
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseRRule, expandRecurrence } from "@suaveplan/datetime/recurrence";
|
||||
|
||||
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6");
|
||||
const dates = expandRecurrence(rule);
|
||||
console.log(dates.length); // 6
|
||||
console.log(dates[0].toDateString()); // next Monday/Wednesday/Friday
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseRRule,
|
||||
formatRRule,
|
||||
nextOccurrence,
|
||||
expandRecurrence,
|
||||
} from "@suaveplan/datetime/recurrence";
|
||||
|
||||
// Round-trip an RRULE string
|
||||
const raw = "FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3";
|
||||
const rule = parseRRule(raw);
|
||||
const formatted = formatRRule(rule);
|
||||
console.log(formatted === raw); // true
|
||||
|
||||
// Deterministic expansion with a fake clock and abort signal
|
||||
const clock = () => new Date("2024-01-01T00:00:00Z").getTime();
|
||||
const controller = new AbortController();
|
||||
const dates = expandRecurrence(
|
||||
parseRRule("FREQ=DAILY;COUNT=365"),
|
||||
{ clock, signal: controller.signal }
|
||||
);
|
||||
console.log(dates.length); // 365
|
||||
|
||||
// Next weekly occurrence on Mondays
|
||||
const next = nextOccurrence(
|
||||
parseRRule("FREQ=WEEKLY;BYDAY=MO"),
|
||||
new Date("2024-03-13") // Wednesday
|
||||
);
|
||||
console.log(next?.toDateString()); // Mon Mar 18 2024
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseRRule(input: string): RecurrenceRule`
|
||||
|
||||
Parse a semicolon-delimited RRULE string. Supported fields:
|
||||
|
||||
- `FREQ` (required): `DAILY | WEEKLY | MONTHLY | YEARLY`
|
||||
- `INTERVAL`: positive integer, defaults to 1
|
||||
- `COUNT`: positive integer
|
||||
- `UNTIL`: `YYYYMMDD` or `YYYYMMDDTHHmmssZ`
|
||||
- `BYDAY`: comma-separated `SU|MO|TU|WE|TH|FR|SA`
|
||||
- `BYMONTHDAY`: comma-separated integers 1–31
|
||||
|
||||
Throws `DatetimeError("DATETIME.PARSE.INVALID_INPUT")` when `FREQ` is missing or unrecognised.
|
||||
|
||||
### `formatRRule(rule: RecurrenceRule): string`
|
||||
|
||||
Serialise a `RecurrenceRule` to an RRULE string. Output is round-trippable through `parseRRule`.
|
||||
|
||||
### `nextOccurrence(rule: RecurrenceRule, from: DateInput): Date | null`
|
||||
|
||||
Returns the first occurrence strictly after `from` that matches all filters. Returns `null` when the rule terminates (`until` exceeded or `count` exhausted) before generating a match.
|
||||
|
||||
### `expandRecurrence(rule: RecurrenceRule, opts?: RecurrenceOptions): Date[]`
|
||||
|
||||
Expand all occurrences into a `Date[]`. Requires `rule.count` or `rule.until`; throws `DatetimeError("DATETIME.RECURRENCE.INFINITE_EXPANSION")` when neither is set.
|
||||
|
||||
- `opts.clock` — injectable `Clock` to set the expansion start point (default: `Date.now`)
|
||||
- `opts.signal` — `AbortSignal` checked every 64 occurrences
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The expansion loop uses an internal safety limit of `count * 10 + 10000` to guard against degenerate filter combinations (e.g. `FREQ=MONTHLY;BYMONTHDAY=31;COUNT=100` in months shorter than 31 days). In practice this limit is never reached for well-formed rules.
|
||||
|
||||
Month arithmetic uses the same clamping strategy as `calendar.addDuration`, so `FREQ=MONTHLY;BYMONTHDAY=31` in February produces no occurrence for that month rather than overflowing into March.
|
||||
|
||||
`nextOccurrence` does not require `count` or `until` since it only generates a single result, but it applies a 3,650-iteration safety cap (~10 years of daily occurrences) before returning `null`.
|
||||
84
docs/foundation/timing/datetime/src/relative/relative.md
Normal file
84
docs/foundation/timing/datetime/src/relative/relative.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# relative
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `relative` module provides human-readable relative-time formatting for `@suaveplan/datetime`. It converts the distance between two moments into concise English phrases like `"3 days ago"`, `"in 2 hours"`, or `"just now"`. The `clock` injection point makes all functions deterministic in tests, while the unified `formatRelative` entry point subsumes the common `timeAgo` / `timeUntil` aliases.
|
||||
|
||||
The module also exposes `diffInUnits`, a lower-level utility that returns the signed numeric difference between two `DateInput` values in any `DurationUnit`. This is useful when callers need the raw integer delta rather than a formatted string.
|
||||
|
||||
## Features
|
||||
|
||||
- `diffInUnits` — signed numeric difference in any `DurationUnit`
|
||||
- `formatRelative` — threshold-based English string (past and future)
|
||||
- `timeAgo` — convenience alias for past-relative formatting
|
||||
- `timeUntil` — convenience alias for future-relative formatting
|
||||
- Pluggable `Clock` for deterministic rendering in tests
|
||||
- `"just now"` threshold for differences under 45 seconds
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { timeAgo, timeUntil } from "@suaveplan/datetime/relative";
|
||||
|
||||
// Using wall clock
|
||||
console.log(timeAgo(new Date(Date.now() - 3 * 60_000))); // "3 minutes ago"
|
||||
console.log(timeUntil(new Date(Date.now() + 2 * 3600_000))); // "in 2 hours"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { diffInUnits, formatRelative } from "@suaveplan/datetime/relative";
|
||||
|
||||
// Deterministic with a fake clock
|
||||
const clock = () => new Date("2024-06-01T12:00:00Z").getTime();
|
||||
|
||||
const pastDate = "2024-05-28T12:00:00Z";
|
||||
console.log(formatRelative(pastDate, { clock })); // "4 days ago"
|
||||
console.log(diffInUnits(pastDate, "2024-06-01T12:00:00Z", "days")); // 4
|
||||
|
||||
// Sub-unit precision
|
||||
const oneHourAgo = new Date(clock() - 3723_000); // 1h 2m 3s ago
|
||||
console.log(formatRelative(oneHourAgo, { clock })); // "1 hour ago"
|
||||
console.log(diffInUnits(oneHourAgo, new Date(clock()), "seconds")); // 3723
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `diffInUnits(a: DateInput, b: DateInput, unit: DurationUnit): number`
|
||||
|
||||
Returns `floor((b − a) / unitMs)` truncated toward zero. Positive when `b > a`.
|
||||
|
||||
- `a` — start date (any `DateInput`)
|
||||
- `b` — end date (any `DateInput`)
|
||||
- `unit` — one of the `DurationUnit` values
|
||||
|
||||
### `formatRelative(date: DateInput, opts?: RelativeTimeOptions): string`
|
||||
|
||||
Format the difference between `date` and `opts.clock()` (default: `Date.now()`).
|
||||
|
||||
- Distances under 45 seconds → `"just now"`
|
||||
- Distances under 45 minutes → `"N seconds ago"` / `"in N seconds"`
|
||||
- Distances under 22 hours → `"N minutes ago"` / `"in N minutes"`
|
||||
- Distances under 26 days → `"N hours ago"` / `"in N hours"`
|
||||
- Distances under 11 months → `"N days ago"` / `"in N days"`
|
||||
- Otherwise → `"N months ago"` / `"in N months"` or `"N years ago"` / `"in N years"`
|
||||
|
||||
### `timeAgo(date: DateInput, opts?: RelativeTimeOptions): string`
|
||||
|
||||
Alias for `formatRelative`. Intended for past dates, but works symmetrically.
|
||||
|
||||
### `timeUntil(date: DateInput, opts?: RelativeTimeOptions): string`
|
||||
|
||||
Alias for `formatRelative`. Intended for future dates, but works symmetrically.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Average month (30.4375 days) and average year (365.25 days) constants are used for the coarse `months` / `years` thresholds. These are approximations intentionally — precise calendar arithmetic for display strings would add complexity without improving user experience. Use `diffInUnits("months")` together with calendar arithmetic when you need exact month counts.
|
||||
|
||||
The threshold table mirrors the conventions used by popular libraries like `date-fns` and `moment.js` so that migrating code produces the same user-visible strings.
|
||||
|
||||
The `precision` field in `RelativeTimeOptions` is reserved for future use to cap the label at a specific unit (e.g. only show up to "days", never "hours"). Currently it is accepted but not applied in the implementation.
|
||||
69
docs/foundation/timing/datetime/src/types/datetime.types.md
Normal file
69
docs/foundation/timing/datetime/src/types/datetime.types.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# datetime.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type definitions for the `@suaveplan/datetime` package. This module is the single source of truth for the shapes that flow through business-day arithmetic, calendar math, recurrence expansion, and relative-time formatting. Co-locating these definitions in one flat file keeps the feature modules focused on behaviour and keeps cross-module contracts obvious at a glance.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero runtime cost — all exports are pure TypeScript types
|
||||
- Branded `Timezone` string for IANA names that prevents accidental raw-string injection
|
||||
- `DurationUnit` union aligned with the full `years | months | weeks | days | hours | minutes | seconds | milliseconds` vocabulary used across the library
|
||||
- `BusinessDayOptions` and `RelativeTimeOptions` carry a pluggable `Clock` from `@suaveplan/types` so every consumer can be driven by a deterministic fake in tests
|
||||
- `RecurrenceRule` subset aligned with the iCalendar/RRULE vocabulary (`freq`, `interval`, `count`, `until`, `byDay`, `byMonthDay`) without the full RFC 5545 complexity
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { Duration, Frequency, Weekday } from "@suaveplan/datetime/types/datetime.types";
|
||||
|
||||
const oneWeek: Duration = { days: 7 };
|
||||
const monday: Weekday = 1;
|
||||
const rule: Frequency = "weekly";
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
BusinessDayOptions,
|
||||
RecurrenceRule,
|
||||
Timezone,
|
||||
} from "@suaveplan/datetime/types/datetime.types";
|
||||
|
||||
const businessOptions: BusinessDayOptions = {
|
||||
holidays: [new Date("2026-01-01")],
|
||||
weekendDays: [0, 6],
|
||||
clock: () => Date.UTC(2026, 0, 1),
|
||||
};
|
||||
|
||||
const weeklyStandup: RecurrenceRule = {
|
||||
freq: "weekly",
|
||||
interval: 1,
|
||||
byDay: [1, 3, 5],
|
||||
count: 52,
|
||||
};
|
||||
|
||||
const nycTz = "America/New_York" as Timezone;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DateInput` — union of `Date | string | number`; accepted by parser helpers.
|
||||
- `DurationUnit` — named calendar/time units.
|
||||
- `Duration` — partial record of `DurationUnit → number`.
|
||||
- `Weekday` — numeric index `0..6` matching `Date.prototype.getDay()`.
|
||||
- `DayAbbreviation` — three-letter English abbreviations.
|
||||
- `Frequency` — `"daily" | "weekly" | "monthly" | "yearly"` recurrence cadence.
|
||||
- `Timezone` — branded IANA-name string.
|
||||
- `CalendarDay` / `CalendarMonth` — grid shapes returned by `getCalendarMonth`.
|
||||
- `BusinessDayOptions` — options bundle for business-day arithmetic with optional holidays, custom weekend days, clock injection, and `AbortSignal`.
|
||||
- `RelativeTimeOptions` — locale, clock, and precision knobs for `formatRelativeTime`.
|
||||
- `RecurrenceRule` — RRULE-like shape consumed by `expandRecurrence`.
|
||||
- `RecurrenceOptions` — signal + clock bundle for the expansion loop.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All interfaces deliberately avoid class-based modelling so they can round-trip through JSON and be used as wire-format types by downstream services. The `Timezone` brand is enforced only at compile time; runtime validation lives in `asTimezone()` in the calendar module, which checks against `Intl.supportedValuesOf('timeZone')`. Consumers should never cast a raw `string` to `Timezone` — always construct via the validator.
|
||||
|
||||
The `Clock` and `Rng` injections come from `@suaveplan/types` (the types-only foundation package) rather than being redefined here, which prevents structural drift between `datetime`, `timing`, `retry`, and every other package that takes a clock.
|
||||
89
docs/foundation/timing/timing/README.md
Normal file
89
docs/foundation/timing/timing/README.md
Normal file
File diff suppressed because one or more lines are too long
101
docs/foundation/timing/timing/src/decay/decay.md
Normal file
101
docs/foundation/timing/timing/src/decay/decay.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# createDecay
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createDecay` creates a stateful decay tracker that interpolates a numeric value from an initial level toward a target over time. It is useful for implementing cooldowns, rate-limit backoff weights, signal attenuation, UI animations, or any scenario where a value must gradually approach a steady state. The design is entirely pull-based: callers advance time by calling `tick(elapsed)` rather than relying on real-time timers.
|
||||
|
||||
## Features
|
||||
|
||||
- Two built-in decay modes: `'exponential'` (asymptotic, never quite reaches target) and `'linear'` (clamped at target after `halfLife` ms)
|
||||
- Custom decay function support: `(elapsed, initial, target) => number`
|
||||
- Stateless decay functions — accumulated elapsed time is tracked internally and passed in, so functions are pure
|
||||
- `reset()` restores the initial value and clears accumulated time
|
||||
- No timers, no global state, no I/O
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createDecay } from "@suaveplan/timing";
|
||||
|
||||
const decay = createDecay({
|
||||
initial: 100,
|
||||
target: 0,
|
||||
fn: "exponential",
|
||||
halfLife: 500,
|
||||
});
|
||||
|
||||
decay.tick(500);
|
||||
console.log(decay.value); // ~50 (half of initial)
|
||||
|
||||
decay.tick(500);
|
||||
console.log(decay.value); // ~25 (half of previous)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createDecay } from "@suaveplan/timing";
|
||||
|
||||
// Custom decay: square-root approach
|
||||
const decay = createDecay({
|
||||
initial: 1000,
|
||||
target: 0,
|
||||
fn: (elapsed, initial, _target) => Math.max(0, initial - Math.sqrt(elapsed) * 10),
|
||||
});
|
||||
|
||||
decay.tick(100); // value = 1000 - sqrt(100)*10 = 900
|
||||
decay.tick(100); // elapsed=200, value = 1000 - sqrt(200)*10 ≈ 858.6
|
||||
|
||||
// Linear mode: reaches target exactly at halfLife and stays there
|
||||
const linear = createDecay({
|
||||
initial: 0,
|
||||
target: 100,
|
||||
fn: "linear",
|
||||
halfLife: 1000,
|
||||
});
|
||||
linear.tick(500); // value = 50
|
||||
linear.tick(600); // elapsed=1100, value = 100 (clamped)
|
||||
linear.reset();
|
||||
console.log(linear.value); // 0
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createDecay(opts: DecayOptions): Decay`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `opts.initial` | `number` | Starting value. |
|
||||
| `opts.target` | `number` | Asymptotic target value. |
|
||||
| `opts.fn` | `DecayFunction \| 'exponential' \| 'linear'` | Decay mode or custom function. |
|
||||
| `opts.halfLife` | `number` (optional) | Half-life in ms. Required for built-ins. Default: `1000`. |
|
||||
|
||||
**Returns:** `Decay`
|
||||
|
||||
---
|
||||
|
||||
### `Decay`
|
||||
|
||||
| Member | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `tick(elapsed)` | `(number) => void` | Advance by `elapsed` ms and recompute value. |
|
||||
| `reset()` | `() => void` | Reset to `initial`, clear accumulated elapsed. |
|
||||
| `value` | `number` (readonly) | Current decayed value. |
|
||||
|
||||
---
|
||||
|
||||
### Built-in Decay Functions
|
||||
|
||||
| Mode | Formula |
|
||||
|------|---------|
|
||||
| `'exponential'` | `target + (initial - target) * exp(-elapsed / halfLife)` |
|
||||
| `'linear'` | `initial + (target - initial) * clamp(elapsed / halfLife, 0, 1)` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Elapsed time is accumulated in a private `_elapsed` variable that grows monotonically across `tick()` calls. This ensures that custom decay functions see the total time elapsed since the last `reset()`, not just the per-tick delta. The built-in exponential formula uses natural logarithm base e, so `halfLife` is the time constant (τ) — not the strict half-life in the physical sense. At `elapsed === halfLife`, the value is `initial / e ≈ 36.8%` of the initial excursion. For true half-life behavior (50% decay), use `halfLife = halfLifeMs / ln(2)`.
|
||||
95
docs/foundation/timing/timing/src/errors/timing-error.md
Normal file
95
docs/foundation/timing/timing/src/errors/timing-error.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# TimingError
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`TimingError` is the package-scoped error class for `@suaveplan/timing`. It extends `SuaveplanError` with a strongly-typed `TimingCode` union, ensuring that every error thrown by the package is identifiable by both `instanceof` checks and the literal `code` property. This makes error handling at call sites both safe and ergonomic.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `SuaveplanError<TimingCode>` for full project error-chain compatibility
|
||||
- Three stable error codes: `TIMING.SCHEDULER.DISPOSED`, `TIMING.COUNTDOWN.INVALID_DURATION`, `TIMING.VECTOR_CLOCK.UNKNOWN_NODE`
|
||||
- Codes defined via `defineCodes` — frozen, type-inferred, no string duplication
|
||||
- Supports optional `cause`, `context`, `traceId`, and `clock` via `SuaveplanErrorOptions`
|
||||
- `code` property narrows to the exact `TimingCode` literal union
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { TimingError, TIMING_CODES } from "@suaveplan/timing";
|
||||
|
||||
throw new TimingError(
|
||||
TIMING_CODES["SCHEDULER.DISPOSED"],
|
||||
"timing.scheduler.disposed",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { TimingError, TIMING_CODES } from "@suaveplan/timing";
|
||||
import { walkCauses } from "@suaveplan/error";
|
||||
|
||||
function assertNotDisposed(disposed: boolean): void {
|
||||
if (disposed) {
|
||||
throw new TimingError(
|
||||
TIMING_CODES["SCHEDULER.DISPOSED"],
|
||||
"timing.scheduler.disposed",
|
||||
{ context: { hint: "Call dispose() only once" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
assertNotDisposed(true);
|
||||
} catch (err) {
|
||||
if (err instanceof TimingError && err.code === "TIMING.SCHEDULER.DISPOSED") {
|
||||
for (const cause of walkCauses(err)) {
|
||||
console.error("cause:", cause.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TIMING_CODES`
|
||||
|
||||
Frozen record produced by `defineCodes("TIMING", [...])`.
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| `"SCHEDULER.DISPOSED"` | `"TIMING.SCHEDULER.DISPOSED"` |
|
||||
| `"COUNTDOWN.INVALID_DURATION"` | `"TIMING.COUNTDOWN.INVALID_DURATION"` |
|
||||
| `"VECTOR_CLOCK.UNKNOWN_NODE"` | `"TIMING.VECTOR_CLOCK.UNKNOWN_NODE"` |
|
||||
|
||||
### `TimingCode`
|
||||
|
||||
```typescript
|
||||
type TimingCode =
|
||||
| "TIMING.SCHEDULER.DISPOSED"
|
||||
| "TIMING.COUNTDOWN.INVALID_DURATION"
|
||||
| "TIMING.VECTOR_CLOCK.UNKNOWN_NODE";
|
||||
```
|
||||
|
||||
### `class TimingError`
|
||||
|
||||
```typescript
|
||||
class TimingError extends SuaveplanError<TimingCode> {
|
||||
constructor(code: TimingCode, messageKey: string, options?: SuaveplanErrorOptions): TimingError;
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `code` — one of the `TimingCode` literals
|
||||
- `messageKey` — i18n key passed to the message resolver
|
||||
- `options` — optional `{ cause, context, traceId, clock }`
|
||||
|
||||
**Throws:** never (constructor)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `noUselessConstructor` biome suppression is intentional: the base class constructor is `protected`, so a public re-declaration is necessary to allow external callers to instantiate `TimingError` directly. This is the same pattern used by `EventsError` and `CryptoError` in this project. The `defineCodes` call is frozen at module load time, so code lookup is O(1) property access with no allocation.
|
||||
116
docs/foundation/timing/timing/src/otel/timing-otel.md
Normal file
116
docs/foundation/timing/timing/src/otel/timing-otel.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# timing-otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`timing-otel` provides lightweight OpenTelemetry instrumentation for the `@suaveplan/timing` package. It exposes two helper functions — one for recording stopwatch elapsed times as a histogram and one for counting timer ticks — using lazy-initialized OTel instruments via `getMeter`. These helpers can be dropped into any timing workflow to emit observability signals without coupling the core timing implementations to OTel directly.
|
||||
|
||||
## Features
|
||||
|
||||
- `recordStopwatchElapsed(ms, attributes?)` — records a histogram observation on `timing.stopwatch.elapsed_ms`
|
||||
- `incrementTimerTick(attributes?)` — increments the counter `timing.timer.tick.total`
|
||||
- `incrementRateLimitRejected(attributes?)` — increments `timing.rate_limit.rejected` each time a `rateLimit` wrapper returns the `RATE_LIMITED` sentinel
|
||||
- `incrementOnceInvocation(attributes?)` — increments `timing.once.invocation` on the first successful run of a `once`-wrapped function
|
||||
- `incrementTimesInvocation(attributes?)` — increments `timing.times.invocation` for each in-budget call of a `times`-wrapped function
|
||||
- `recordSchedulerQueueDepth(depth, attributes?)` — latches the current scheduler queue depth for the observable gauge `timing.scheduler.queue.depth`
|
||||
- `setTimingMeterProvider(provider | undefined)` — swap the `MeterProvider` used by every instrument; intended for spec-conformance tests that supply an in-memory provider and assert counter emissions deterministically
|
||||
- Lazy instrument initialization — no OTel SDK required until first call
|
||||
- Optional attribute maps for dimensional labeling
|
||||
- Zero side effects on module load
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createStopwatch } from "@suaveplan/timing";
|
||||
import { recordStopwatchElapsed } from "@suaveplan/timing";
|
||||
|
||||
const sw = createStopwatch();
|
||||
sw.start();
|
||||
// ... do work ...
|
||||
sw.stop();
|
||||
|
||||
recordStopwatchElapsed(sw.elapsed, { operation: "my-task" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createTimer } from "@suaveplan/timing";
|
||||
import { incrementTimerTick, recordStopwatchElapsed } from "@suaveplan/timing";
|
||||
import { createStopwatch } from "@suaveplan/timing";
|
||||
|
||||
let totalTicks = 0;
|
||||
const timer = createTimer({ interval: 1000 }, () => {
|
||||
totalTicks++;
|
||||
incrementTimerTick({ timer_id: "heartbeat" });
|
||||
});
|
||||
timer.start();
|
||||
|
||||
// Track a task's duration and report it
|
||||
async function trackedTask(name: string, fn: () => Promise<void>): Promise<void> {
|
||||
const sw = createStopwatch();
|
||||
sw.start();
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
sw.stop();
|
||||
recordStopwatchElapsed(sw.elapsed, { task: name, status: "ok" });
|
||||
}
|
||||
}
|
||||
|
||||
await trackedTask("database-query", async () => {
|
||||
// ... query
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `recordStopwatchElapsed(elapsedMs: number, attributes?: Record<string, string | number | boolean>): void`
|
||||
|
||||
Records `elapsedMs` to the OTel histogram `timing.stopwatch.elapsed_ms`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `elapsedMs` — elapsed duration in milliseconds
|
||||
- `attributes` — optional dimensional attributes (e.g. `{ operation: "fetch" }`)
|
||||
|
||||
---
|
||||
|
||||
### `incrementTimerTick(attributes?: Record<string, string | number | boolean>): void`
|
||||
|
||||
Adds 1 to the OTel counter `timing.timer.tick.total`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `attributes` — optional dimensional attributes (e.g. `{ timer_id: "heartbeat" }`)
|
||||
|
||||
---
|
||||
|
||||
### `incrementRateLimitRejected(attributes?)` / `incrementOnceInvocation(attributes?)` / `incrementTimesInvocation(attributes?)`
|
||||
|
||||
Increment the `timing.rate_limit.rejected`, `timing.once.invocation`, and `timing.times.invocation` counters respectively. Each helper accepts an optional attribute map matching the OTel `Attributes` interface.
|
||||
|
||||
---
|
||||
|
||||
### `setTimingMeterProvider(provider: MeterProvider | undefined): void`
|
||||
|
||||
Swap the `MeterProvider` instance used by every timing instrument. Passing `undefined` reverts to the global provider. The internal instrument cache is cleared so the next emission rebuilds against the new provider — this is what enables in-memory provider stubs in tests.
|
||||
|
||||
---
|
||||
|
||||
### OTel Instruments
|
||||
|
||||
| Instrument | Type | Unit | Description |
|
||||
|------------|------|------|-------------|
|
||||
| `timing.stopwatch.elapsed_ms` | Histogram | `ms` | Elapsed time from stopwatch measurements |
|
||||
| `timing.timer.tick.total` | Counter | — | Cumulative timer tick count |
|
||||
| `timing.rate_limit.rejected` | Counter | — | Calls rejected by a `rateLimit` window cap |
|
||||
| `timing.once.invocation` | Counter | — | First-and-only successful `once` invocations |
|
||||
| `timing.times.invocation` | Counter | — | In-budget `times` invocations |
|
||||
| `timing.scheduler.queue.depth` | ObservableGauge | — | Current scheduler queue depth |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every instrument is lazy-initialized on first use via module-level `undefined` variables. The `getMeter` call uses the package name `"@suaveplan/timing"` as the meter name. If no OTel SDK is registered, the default no-op implementation is used, so these calls are safe in environments without instrumentation. The attribute type is `Record<string, string | number | boolean>` to match the OTel `Attributes` interface without importing OTel types at the call site. Spec-conformance tests inject an in-memory `MeterProvider` via `setTimingMeterProvider`, which both flips the source of new meters and clears the cache so the next instrument access rebuilds against the test provider.
|
||||
107
docs/foundation/timing/timing/src/scheduler/scheduler.md
Normal file
107
docs/foundation/timing/timing/src/scheduler/scheduler.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# createScheduler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createScheduler` creates a lifecycle-managed task scheduler that wraps `setTimeout` with tracking, cancellation, and `drain()` support. It is intended for coordinating delayed side effects in server-side workflows where you need to ensure all in-flight tasks complete before shutdown, or cancel them cleanly on disposal.
|
||||
|
||||
## Features
|
||||
|
||||
- Schedule async tasks with a delay in milliseconds
|
||||
- Cancel individual tasks by handle or by ID
|
||||
- `drain()` awaits all pending tasks, with optional `AbortSignal` for early termination
|
||||
- `dispose()` cancels all pending tasks and prevents new ones from being scheduled
|
||||
- All post-dispose operations throw `TimingError(TIMING.SCHEDULER.DISPOSED)`
|
||||
- Each scheduled task returns a `SchedulerHandle` with an `id` and `cancel()` method
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createScheduler } from "@suaveplan/timing";
|
||||
|
||||
const scheduler = createScheduler();
|
||||
|
||||
const handle = scheduler.schedule(async () => {
|
||||
console.log("Task fired after 100ms");
|
||||
}, 100);
|
||||
|
||||
// Cancel before it fires
|
||||
handle.cancel();
|
||||
|
||||
await scheduler.drain();
|
||||
scheduler.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createScheduler } from "@suaveplan/timing";
|
||||
|
||||
const scheduler = createScheduler();
|
||||
const results: string[] = [];
|
||||
|
||||
scheduler.schedule(async () => { results.push("a"); }, 10);
|
||||
scheduler.schedule(async () => { results.push("b"); }, 20);
|
||||
scheduler.schedule(async () => { results.push("c"); }, 30);
|
||||
|
||||
// Wait for all tasks to complete
|
||||
await scheduler.drain();
|
||||
console.log(results); // ["a", "b", "c"]
|
||||
|
||||
// Abort drain early
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("shutdown"));
|
||||
|
||||
scheduler.schedule(async () => {}, 10000);
|
||||
|
||||
try {
|
||||
await scheduler.drain(controller.signal);
|
||||
} catch (err) {
|
||||
console.log("Drained aborted:", err);
|
||||
}
|
||||
|
||||
scheduler.dispose();
|
||||
// Further operations throw TimingError(TIMING.SCHEDULER.DISPOSED)
|
||||
try {
|
||||
scheduler.schedule(() => {}, 0);
|
||||
} catch (e) {
|
||||
console.log(e.code); // "TIMING.SCHEDULER.DISPOSED"
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createScheduler(opts?: SchedulerOptions): Scheduler`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `opts.clock` — optional `Clock` (accepted for API symmetry; scheduling uses real `setTimeout`)
|
||||
|
||||
**Returns:** `Scheduler`
|
||||
|
||||
---
|
||||
|
||||
### `Scheduler`
|
||||
|
||||
| Member | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `schedule(fn, delay)` | `(fn: () => void \| Promise<void>, delay: number) => SchedulerHandle` | Schedule a task. Throws if disposed. |
|
||||
| `cancel(id)` | `(id: number) => void` | Cancel a task by ID. Throws if disposed. |
|
||||
| `drain(signal?)` | `(signal?: AbortSignal) => Promise<void>` | Wait for all pending tasks. |
|
||||
| `dispose()` | `() => void` | Cancel all tasks and prevent new ones. |
|
||||
| `disposed` | `boolean` (readonly) | Whether the scheduler has been disposed. |
|
||||
|
||||
---
|
||||
|
||||
### `SchedulerHandle`
|
||||
|
||||
| Member | Description |
|
||||
|--------|-------------|
|
||||
| `id` | Unique numeric ID. |
|
||||
| `cancel()` | Cancel this task. No-op if already fired or cancelled. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Each task is stored in a `Map<number, Task>`. The `resolve` function on each task is used both by the timer callback (after the async fn completes) and by `cancel()`/`dispose()`. The `drain()` implementation wraps the `resolve` of each in-flight task so that its own promise settles when all tasks complete. The `AbortSignal` listener is registered with `{ once: true }` to avoid listener leaks. Disposing clears all timers and resolves all pending drain promises.
|
||||
95
docs/foundation/timing/timing/src/stopwatch/stopwatch.md
Normal file
95
docs/foundation/timing/timing/src/stopwatch/stopwatch.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# createStopwatch
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createStopwatch` creates a lap-capable stopwatch that accumulates elapsed time across multiple start/stop cycles. It is designed for precise, deterministic time measurement in both production code and unit tests, with full support for clock injection so time can be controlled in test scenarios.
|
||||
|
||||
## Features
|
||||
|
||||
- Accumulates elapsed time correctly across multiple start/stop cycles
|
||||
- Lap recording with per-lap `elapsed` (since last start) and `split` (since last lap) times
|
||||
- Pluggable `clock` option — inject a custom `Clock` for deterministic testing
|
||||
- `running` state guard: `start()` and `stop()` are idempotent
|
||||
- `reset()` clears accumulated time, laps, and stops the stopwatch
|
||||
- All state is encapsulated; no global side effects
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createStopwatch } from "@suaveplan/timing";
|
||||
|
||||
const sw = createStopwatch();
|
||||
sw.start();
|
||||
// ... do work ...
|
||||
sw.stop();
|
||||
console.log(`Elapsed: ${sw.elapsed}ms`);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createStopwatch } from "@suaveplan/timing";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
// Inject a fake clock for deterministic testing
|
||||
let fakeTime = 0;
|
||||
const clock: Clock = () => fakeTime;
|
||||
|
||||
const sw = createStopwatch({ clock });
|
||||
sw.start();
|
||||
|
||||
fakeTime = 100;
|
||||
const lap1 = sw.lap(); // { index: 0, elapsed: 100, split: 100 }
|
||||
|
||||
fakeTime = 250;
|
||||
const lap2 = sw.lap(); // { index: 1, elapsed: 250, split: 150 }
|
||||
|
||||
sw.stop();
|
||||
console.log(sw.elapsed); // 250
|
||||
console.log(sw.laps); // [lap1, lap2]
|
||||
|
||||
sw.reset();
|
||||
console.log(sw.elapsed); // 0
|
||||
console.log(sw.running); // false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createStopwatch(opts?: StopwatchOptions): Stopwatch`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `opts.clock` — optional `Clock` (a `() => number` function). Defaults to `Date.now`.
|
||||
|
||||
**Returns:** `Stopwatch`
|
||||
|
||||
---
|
||||
|
||||
### `Stopwatch`
|
||||
|
||||
| Member | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `start()` | `() => void` | Start or resume the stopwatch. No-op if already running. |
|
||||
| `stop()` | `() => void` | Pause the stopwatch. Accumulates elapsed time. No-op if already stopped. |
|
||||
| `reset()` | `() => void` | Clear all accumulated time and laps. Stops if running. |
|
||||
| `lap()` | `() => StopwatchLap` | Record a lap. Returns lap data. |
|
||||
| `elapsed` | `number` (readonly) | Total milliseconds elapsed including current running segment. |
|
||||
| `laps` | `StopwatchLap[]` (readonly) | Copy of all recorded laps. |
|
||||
| `running` | `boolean` (readonly) | Whether the stopwatch is currently running. |
|
||||
|
||||
---
|
||||
|
||||
### `StopwatchLap`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `index` | `number` | Zero-based lap index. |
|
||||
| `elapsed` | `number` | Total elapsed ms at the time of the lap. |
|
||||
| `split` | `number` | Ms elapsed since the previous lap (or since start for the first lap). |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Elapsed time is accumulated in a private `_accumulated` variable. When the stopwatch is running, `getElapsed()` adds the current segment (`now() - _startedAt`) to the accumulated value on every read, avoiding any timer drift. The `laps` getter returns a shallow copy to prevent external mutation of the internal array. The `lap()` method records the elapsed time at the instant of the call, making it safe to call while the stopwatch is stopped (it captures the final accumulated value).
|
||||
94
docs/foundation/timing/timing/src/throttle/once/once.md
Normal file
94
docs/foundation/timing/timing/src/throttle/once/once.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# once
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`once` wraps a function so that it executes exactly once and caches the result for every subsequent call. It is the lowest-friction way to express "do this lazy initialisation step the first time it is asked for, and never again" — e.g. warming a database pool, lazily reading a configuration file, or building an expensive in-memory index. Unlike a hand-written `let initialised = false; if (!initialised) { ... }` guard scattered across every call site, `once` localises the dedup primitive next to the work itself.
|
||||
|
||||
## Features
|
||||
|
||||
- Exact-once execution semantics: the first call runs `fn(...args)` and stashes the return; every later call returns the stashed value without re-invoking `fn`.
|
||||
- Strict return type `TReturn` (not `TReturn | undefined`) — synchronous callers don't have to narrow before using the result.
|
||||
- Throw-and-retry behaviour: when `fn` throws, the wrapper re-throws and stays in the not-yet-run state, so the next call retries from scratch. This is a deliberate improvement over the upstream legacy implementation, which set `called = true` before invoking `fn` and therefore silently returned `undefined` on every retry after a throw.
|
||||
- Argument forwarding: the wrapper accepts the same parameter list as `fn` and forwards arguments verbatim on the run-it call. Subsequent calls ignore their arguments and return the cached value — there is no per-argument memoization (use a keyed cache or `@suaveplan/idempotency` if that is what you need).
|
||||
- OTel counter `timing.once.invocation` increments only on the first successful run.
|
||||
- Tree-shakeable subpath export `@suaveplan/timing/throttle/once`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { once } from "@suaveplan/timing/throttle/once";
|
||||
|
||||
const buildIndex = once(() => {
|
||||
console.log("building...");
|
||||
return new Map<string, number>([
|
||||
["alpha", 1],
|
||||
["beta", 2],
|
||||
]);
|
||||
});
|
||||
|
||||
const a = buildIndex(); // logs "building..."
|
||||
const b = buildIndex(); // no log
|
||||
console.log(a === b); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { once } from "@suaveplan/timing/throttle/once";
|
||||
|
||||
interface ConnectionPool {
|
||||
query(sql: string): Promise<unknown>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const connect = (): ConnectionPool => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("DNS not ready");
|
||||
}
|
||||
return {
|
||||
query: async () => ({}),
|
||||
close: async () => {},
|
||||
};
|
||||
};
|
||||
|
||||
const getPool = once(connect);
|
||||
|
||||
try {
|
||||
getPool();
|
||||
} catch (err) {
|
||||
console.warn("connect failed, will retry:", err);
|
||||
}
|
||||
// Because the previous attempt threw, the wrapper stayed "not-yet-run".
|
||||
// The next call retries the connect logic from scratch.
|
||||
const pool = getPool();
|
||||
await pool.query("SELECT 1");
|
||||
console.log(attempts); // 2 — first attempt threw, second succeeded
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `once<TArgs, TReturn>(fn): (...args: TArgs) => TReturn`
|
||||
|
||||
Wrap `fn` so it executes exactly once.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- `fn` — the function to wrap.
|
||||
|
||||
**Returns** a wrapper with the same parameter list as `fn` and return type `TReturn`.
|
||||
|
||||
**Throws** whatever `fn` throws on the run-it call. After a thrown call the wrapper remains not-yet-run, so the next call retries `fn`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wrapper holds two pieces of state: a boolean `called` flag and the cached `result`. The critical ordering is "invoke first, then mark called" — if `fn` throws, `called` is never flipped to `true`, and a subsequent call re-enters the run path. This is the only semantic difference from the upstream `once`, which inverted the ordering and consequently locked the wrapper into an "I ran but produced `undefined`" state on any throw. The counter increment is fenced behind the success path for the same reason: an exception means no successful run happened, so no increment.
|
||||
|
||||
**Differences from upstream**
|
||||
|
||||
The legacy `../suaveplan/packages/universal/timing/src/throttle/throttle.ts::once` set `called = true` *before* the `result = func(...)` assignment. A thrown call therefore left the wrapper permanently "called" with `result` undefined, and every retry silently returned `undefined`. suaveplan fixes this by ordering "invoke, assign, mark called" so the failure mode degrades safely to retry. The return type is also tightened from `TReturn | undefined` to `TReturn` — the wrapper never returns `undefined` unless `fn` itself does, in which case `TReturn` already includes `undefined`.
|
||||
@@ -0,0 +1,112 @@
|
||||
# rate-limit
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`rateLimit` wraps a function so that at most `maxCalls` invocations of the underlying `fn` are permitted inside any sliding window of `windowMs` milliseconds. Where `throttle` spaces evenly-distributed calls across an interval, `rateLimit` lets callers burst freely up to the cap and rejects further calls with a typed sentinel until the window slides forward. This is the right primitive for REST clients, webhook deliverers, feed pollers, and any consumer of an upstream API that publishes its limits as "N requests per M seconds."
|
||||
|
||||
## Features
|
||||
|
||||
- Sliding-window semantics implemented as a FIFO of timestamps — entries older than `now - windowMs` are evicted on every call so the window is always exact.
|
||||
- Typed sentinel `RATE_LIMITED` (a unique `symbol`) returned when the cap is hit. Reference equality against the sentinel disambiguates "rate-limited" from a legitimate `undefined` return value when `TReturn` is `void`.
|
||||
- `RateLimitedSentinel` type alias (`typeof RATE_LIMITED`) for narrowing in caller code.
|
||||
- Injectable `Clock` from `@suaveplan/types` so window math is fully deterministic under `FakeClock` in tests.
|
||||
- Zod-validated options at construction time. `maxCalls < 1` or `windowMs <= 0` throws `TimingError` with code `TIMING.RATE_LIMIT_INVALID_WINDOW`.
|
||||
- OTel counter `timing.rate_limit.rejected` increments on every sentinel return for production observability.
|
||||
- Tree-shakeable subpath export `@suaveplan/timing/throttle/rate-limit`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
RATE_LIMITED,
|
||||
rateLimit,
|
||||
} from "@suaveplan/timing/throttle/rate-limit";
|
||||
|
||||
const fetchUser = (id: string): Promise<unknown> =>
|
||||
fetch(`/api/users/${id}`).then((r) => r.json());
|
||||
|
||||
// At most 5 requests per second to /api/users.
|
||||
const limited = rateLimit(fetchUser, { maxCalls: 5, windowMs: 1000 });
|
||||
|
||||
const result = limited("alice");
|
||||
if (result === RATE_LIMITED) {
|
||||
// Back off and retry later.
|
||||
console.warn("rate limited");
|
||||
} else {
|
||||
const user = await result;
|
||||
console.log(user);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FakeClock } from "@suaveplan/testing";
|
||||
import {
|
||||
RATE_LIMITED,
|
||||
rateLimit,
|
||||
type RateLimitedSentinel,
|
||||
} from "@suaveplan/timing/throttle/rate-limit";
|
||||
|
||||
// Deterministic window math under a fake clock.
|
||||
const clock = new FakeClock();
|
||||
let invocations = 0;
|
||||
const wrapped = rateLimit(
|
||||
(n: number): number => {
|
||||
invocations += 1;
|
||||
return n * 2;
|
||||
},
|
||||
{ maxCalls: 2, windowMs: 100, clock: () => clock.now() }
|
||||
);
|
||||
|
||||
const r1 = wrapped(1); // 2
|
||||
const r2 = wrapped(2); // 4
|
||||
const r3 = wrapped(3); // RATE_LIMITED — sentinel
|
||||
clock.advance(101); // window slides
|
||||
const r4 = wrapped(4); // 8
|
||||
|
||||
const isLimited = (
|
||||
v: number | RateLimitedSentinel
|
||||
): v is RateLimitedSentinel => v === RATE_LIMITED;
|
||||
|
||||
console.log({ r1, r2, r3: isLimited(r3), r4, invocations });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `RATE_LIMITED: unique symbol`
|
||||
|
||||
The sentinel returned by a `rateLimit`-wrapped function when the call would exceed the cap. Imported alongside `rateLimit` from the same subpath.
|
||||
|
||||
### `rateLimit<TArgs, TReturn>(fn, options): (...args: TArgs) => TReturn | RateLimitedSentinel`
|
||||
|
||||
Wrap `fn` with a sliding-window rate limit.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- `fn` — the function to wrap.
|
||||
- `options.maxCalls: number` — maximum in-window calls. MUST be an integer `>= 1`.
|
||||
- `options.windowMs: number` — sliding-window size in milliseconds. MUST be `> 0`.
|
||||
- `options.clock?: Clock` — optional clock from `@suaveplan/types`. Defaults to `Date.now`.
|
||||
|
||||
**Returns** a wrapper with the same parameter list as `fn` and return type `TReturn | RateLimitedSentinel`.
|
||||
|
||||
**Throws** `TimingError` with code `TIMING.RATE_LIMIT_INVALID_WINDOW` when validation fails.
|
||||
|
||||
### `RateLimitOptionsSchema`
|
||||
|
||||
The Zod schema enforced at construction time. Exported for callers building higher-level helpers that need to validate the same shape ahead of time.
|
||||
|
||||
### `type RateLimitedSentinel = typeof RATE_LIMITED`
|
||||
|
||||
Type alias for the sentinel, useful in narrowing functions.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The FIFO uses a plain `number[]` of timestamps. Eviction is O(window) amortised — every push is balanced by at most one shift later. The wrapper itself holds no timers and is fully synchronous; it works identically with synchronous and asynchronous `fn` because it never inspects the return value. The OTel counter is incremented at the rejection site, not inside the wrapper factory, so factories that are constructed but never called emit nothing.
|
||||
|
||||
The legacy upstream signature was `rateLimit(func, maxCalls, windowMs, _options)` and returned `TReturn | undefined`. The suaveplan surface keeps the same semantics but reshapes the call site to `(fn, { maxCalls, windowMs, clock })` for ergonomic parity with `throttle` and `debounce`, and returns the typed sentinel so functions with `TReturn = void` are no longer ambiguous. The change is additive: there is no legacy callable to remove .
|
||||
103
docs/foundation/timing/timing/src/throttle/throttle.md
Normal file
103
docs/foundation/timing/timing/src/throttle/throttle.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# throttle / debounce
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`throttle` and `debounce` are rate-limiting utilities for controlling how frequently a function can be called. `throttle` enforces a minimum time window between invocations, while `debounce` delays invocation until a quiet period has elapsed. Both functions are designed without global state and accept an injectable `clock` for deterministic testing.
|
||||
|
||||
## Features
|
||||
|
||||
- `throttle`: configurable leading/trailing edge, pluggable clock
|
||||
- `debounce`: configurable leading/trailing edge, optional `maxWait` to guarantee progress during sustained bursts
|
||||
- `.cancel()` clears any pending timer and resets internal state
|
||||
- `.flush()` immediately fires a pending trailing invocation
|
||||
- No global timers or side effects outside the returned function closure
|
||||
- Full TypeScript generics preserve the original function signature
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { throttle, debounce } from "@suaveplan/timing";
|
||||
|
||||
// Throttle scroll handler to fire at most once per 100ms
|
||||
const onScroll = throttle((event: Event) => {
|
||||
console.log("scroll", event);
|
||||
}, 100);
|
||||
|
||||
// Debounce search input to fire 300ms after the user stops typing
|
||||
const onSearch = debounce((query: string) => {
|
||||
fetch(`/api/search?q=${query}`);
|
||||
}, 300);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { throttle, debounce } from "@suaveplan/timing";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
// Inject fake clock for deterministic tests
|
||||
let fakeTime = 0;
|
||||
const clock: Clock = () => fakeTime;
|
||||
|
||||
const calls: number[] = [];
|
||||
const fn = throttle((n: number) => { calls.push(n); }, 100, { clock, trailing: true });
|
||||
|
||||
fn(1); // fires immediately (leading)
|
||||
fakeTime = 50;
|
||||
fn(2); // throttled, pending as trailing
|
||||
fakeTime = 100;
|
||||
fn.flush(); // fires trailing with arg 2
|
||||
|
||||
// Debounce with maxWait to guarantee forward progress
|
||||
let value = 0;
|
||||
const guaranteed = debounce(() => { value++; }, 500, { maxWait: 1000 });
|
||||
for (let i = 0; i < 100; i++) {
|
||||
fakeTime += 10;
|
||||
guaranteed(); // maxWait ensures it fires after 1000ms even during burst
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `throttle<T>(fn: T, wait: number, opts?: ThrottleOptions): ThrottledFunction<T>`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `fn` — the function to throttle
|
||||
- `wait` — minimum milliseconds between invocations
|
||||
- `opts.leading` — fire on the leading edge (default: `true`)
|
||||
- `opts.trailing` — fire on the trailing edge (default: `true`)
|
||||
- `opts.clock` — custom `Clock` (default: `Date.now`)
|
||||
|
||||
**Returns:** `ThrottledFunction<T>` — same signature as `fn`, with `.cancel()` and `.flush()` methods.
|
||||
|
||||
---
|
||||
|
||||
### `debounce<T>(fn: T, wait: number, opts?: DebounceOptions): DebouncedFunction<T>`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `fn` — the function to debounce
|
||||
- `wait` — quiet period in milliseconds before invocation
|
||||
- `opts.leading` — fire on the first call (default: `false`)
|
||||
- `opts.trailing` — fire after the quiet period (default: `true`)
|
||||
- `opts.maxWait` — maximum ms the call can be delayed (default: none)
|
||||
- `opts.clock` — custom `Clock` (default: `Date.now`)
|
||||
|
||||
**Returns:** `DebouncedFunction<T>` — same signature as `fn`, with `.cancel()` and `.flush()` methods.
|
||||
|
||||
---
|
||||
|
||||
### `ThrottledFunction<T>` / `DebouncedFunction<T>`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `.cancel()` | Cancel any pending trailing invocation and reset timers. |
|
||||
| `.flush()` | Immediately fire the pending trailing invocation, if any. Returns the result or `undefined`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both implementations track `lastCallTime` and `lastInvokeTime` separately, allowing the trailing edge to be suppressed if no new calls arrived during the wait window. The `shouldInvoke` predicate guards every timer callback. `maxWait` in `debounce` is implemented by shortcutting `remainingWait` to the minimum of the normal wait remaining and the max-wait remaining. The clock is read via a closure so swapping it at call time (rather than construction time) works correctly in tests.
|
||||
88
docs/foundation/timing/timing/src/throttle/times/times.md
Normal file
88
docs/foundation/timing/timing/src/throttle/times/times.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# times
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`times` wraps a function so it runs for the first `n` invocations and becomes a no-op thereafter. It is the right primitive for emitting bounded log lines ("warn the first 5 times we hit this branch, then stop"), one-shot prompts repeated across many call sites, or any "first N then nothing" policy that would otherwise leak hand-rolled counters into business logic. The wrapper is purely synchronous — the bound is on the number of executions, not on wall time — so it composes cleanly with `throttle` and `rateLimit` when both behaviours are wanted.
|
||||
|
||||
## Features
|
||||
|
||||
- Run-N-then-stop semantics: the first `maxCalls` invocations call `fn(...args)` and return its value; every later invocation returns `undefined` without invoking `fn`.
|
||||
- `maxCalls <= 0` is a valid no-op wrapper — the wrapper never runs `fn` and always returns `undefined`. This is intentional and avoids an error path for an unambiguous policy.
|
||||
- Argument forwarding: each in-budget call forwards arguments verbatim. Out-of-budget calls discard their arguments.
|
||||
- OTel counter `timing.times.invocation` increments for each in-budget call (i.e. not for the no-op tail).
|
||||
- Tree-shakeable subpath export `@suaveplan/timing/throttle/times`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { times } from "@suaveplan/timing/throttle/times";
|
||||
|
||||
const warnOnce = times(
|
||||
(message: string) => console.warn(message),
|
||||
5
|
||||
);
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
warnOnce(`run ${i}`); // logs the first 5, no-ops the rest
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { times } from "@suaveplan/timing/throttle/times";
|
||||
|
||||
interface Sample {
|
||||
id: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const captureBoot = times(
|
||||
(sample: Sample): Sample => {
|
||||
// Persist the first three boot-time samples for later inspection.
|
||||
// Anything after that is dropped.
|
||||
return { ...sample, captured: true } as Sample;
|
||||
},
|
||||
3
|
||||
);
|
||||
|
||||
const incoming: Sample[] = [
|
||||
{ id: "a", value: 1 },
|
||||
{ id: "b", value: 2 },
|
||||
{ id: "c", value: 3 },
|
||||
{ id: "d", value: 4 },
|
||||
{ id: "e", value: 5 },
|
||||
];
|
||||
|
||||
const captured = incoming.map(captureBoot);
|
||||
// captured = [
|
||||
// { id: "a", value: 1, captured: true },
|
||||
// { id: "b", value: 2, captured: true },
|
||||
// { id: "c", value: 3, captured: true },
|
||||
// undefined,
|
||||
// undefined,
|
||||
// ]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `times<TArgs, TReturn>(fn, maxCalls): (...args: TArgs) => TReturn | undefined`
|
||||
|
||||
Wrap `fn` so it runs at most `maxCalls` times.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- `fn` — the function to wrap.
|
||||
- `maxCalls: number` — maximum number of times `fn` is allowed to run. Values `<= 0` produce a wrapper that always returns `undefined`.
|
||||
|
||||
**Returns** a wrapper with the same parameter list as `fn` and return type `TReturn | undefined`.
|
||||
|
||||
The wrapper never throws on its own; any throw originates inside `fn`. The counter increment is fenced behind the `fn` invocation so that exceptions from `fn` do not also produce a misleading counter tick.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wrapper holds a single integer `callCount` that increments on every in-budget call. The comparison is `callCount >= maxCalls`, which makes `maxCalls = 0` a permanent no-op and `maxCalls = n` produce exactly `n` runs. The counter increment lives after the `fn(...args)` call so that an exception from `fn` propagates without ticking the counter (matching `once`'s pattern); the budget counter still advances, however, because the call did consume one of the `maxCalls` slots.
|
||||
127
docs/foundation/timing/timing/src/timer/timer.md
Normal file
127
docs/foundation/timing/timing/src/timer/timer.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# createTimer / createCountdown
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createTimer` creates a repeating interval timer that fires a callback on a configurable schedule. `createCountdown` creates a one-shot countdown that resolves a promise when the duration expires, with pause/resume/stop lifecycle control. Both support `AbortSignal` for external cancellation and a pluggable `clock` for testing.
|
||||
|
||||
## Features
|
||||
|
||||
- `createTimer`: repeating `setInterval`-backed ticker with start/stop/reset and `AbortSignal` support
|
||||
- `createCountdown`: promise-based countdown with pause/resume/stop, remaining time tracking, and signal support
|
||||
- `createCountdown` throws `TimingError(TIMING.COUNTDOWN.INVALID_DURATION)` for non-positive durations
|
||||
- `remaining` property updates live while running
|
||||
- Idempotent `start()`/`stop()` — safe to call multiple times
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createTimer, createCountdown } from "@suaveplan/timing";
|
||||
|
||||
// Repeating timer
|
||||
let ticks = 0;
|
||||
const timer = createTimer({ interval: 100 }, () => { ticks++; });
|
||||
timer.start();
|
||||
// ...later
|
||||
timer.stop();
|
||||
|
||||
// Countdown
|
||||
const countdown = createCountdown(5000);
|
||||
countdown.start().then(() => console.log("done"));
|
||||
// ...later
|
||||
countdown.pause();
|
||||
// ...later
|
||||
countdown.resume();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createTimer, createCountdown } from "@suaveplan/timing";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
// AbortSignal integration
|
||||
const ac = new AbortController();
|
||||
const timer = createTimer({ interval: 50, signal: ac.signal }, () => {});
|
||||
timer.start();
|
||||
ac.abort(); // stops the timer externally
|
||||
|
||||
// Countdown with clock injection
|
||||
let fakeTime = 0;
|
||||
const clock: Clock = () => fakeTime;
|
||||
|
||||
const cd = createCountdown(1000, { clock });
|
||||
const done = cd.start();
|
||||
fakeTime = 500;
|
||||
cd.pause();
|
||||
console.log(cd.remaining); // ~500
|
||||
|
||||
fakeTime = 600;
|
||||
cd.resume();
|
||||
// setTimeout will fire after real 500ms (remaining), then:
|
||||
// await done → resolves
|
||||
|
||||
// Error case
|
||||
try {
|
||||
createCountdown(0);
|
||||
} catch (e) {
|
||||
console.log(e.code); // "TIMING.COUNTDOWN.INVALID_DURATION"
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createTimer(opts: TimerOptions, onTick: () => void): Timer`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `opts.interval` — interval between ticks in milliseconds
|
||||
- `opts.clock` — optional `Clock` (reserved for future use)
|
||||
- `opts.signal` — optional `AbortSignal` to stop the timer externally
|
||||
- `onTick` — callback fired on each tick
|
||||
|
||||
**Returns:** `Timer`
|
||||
|
||||
---
|
||||
|
||||
### `Timer`
|
||||
|
||||
| Member | Description |
|
||||
|--------|-------------|
|
||||
| `start()` | Start the interval. No-op if already ticking or signal is aborted. |
|
||||
| `stop()` | Stop the interval. |
|
||||
| `reset()` | Stop the interval (same as stop; resets readiness). |
|
||||
| `ticking` | `boolean` — whether the timer is currently running. |
|
||||
|
||||
---
|
||||
|
||||
### `createCountdown(durationMs: number, opts?: CountdownOptions): Countdown`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `durationMs` — total countdown duration in milliseconds (must be > 0)
|
||||
- `opts.clock` — optional `Clock` for `remaining` computation
|
||||
- `opts.signal` — optional `AbortSignal`; aborts the `start()` promise
|
||||
|
||||
**Throws:** `TimingError(TIMING.COUNTDOWN.INVALID_DURATION)` if `durationMs <= 0`
|
||||
|
||||
**Returns:** `Countdown`
|
||||
|
||||
---
|
||||
|
||||
### `Countdown`
|
||||
|
||||
| Member | Description |
|
||||
|--------|-------------|
|
||||
| `start()` | Start countdown. Returns a `Promise<void>` that resolves on expiry. |
|
||||
| `pause()` | Pause, preserving `remaining`. No-op if not running. |
|
||||
| `resume()` | Resume from `remaining`. No-op if already running. |
|
||||
| `stop()` | Stop and reset `remaining` to full duration. |
|
||||
| `remaining` | Live remaining milliseconds (decrements while running). |
|
||||
| `running` | Whether the countdown is currently active. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`createCountdown` stores the promise `resolve`/`reject` callbacks in the closure so that `pause()`, `resume()`, and external abort can all interact with the same promise chain. The `remaining` getter computes the live value as `_remaining - (now() - _startedAt)` while running and returns the stored `_remaining` while paused or stopped. The `AbortSignal` listener is registered with `{ once: true }` to prevent listener accumulation across multiple `start()` calls.
|
||||
109
docs/foundation/timing/timing/src/vector-clock/vector-clock.md
Normal file
109
docs/foundation/timing/timing/src/vector-clock/vector-clock.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# createVectorClock / vectorClockCompare
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createVectorClock` implements a Lamport-style vector clock for tracking causality in distributed systems. Each clock instance is associated with a node ID and maintains a counter per known node. Events are ordered using the happens-before relation: `increment()` advances the local counter, `update()` merges a remote state via component-wise max, and `compare()` returns the causal relationship between two states.
|
||||
|
||||
## Features
|
||||
|
||||
- Component-wise max merge on `update()` (receive a message)
|
||||
- `increment()` advances only the local node's counter
|
||||
- `compare()` returns `'before'`, `'after'`, `'concurrent'`, or `'equal'`
|
||||
- `toJSON()`/`fromJSON()` for serialization round-trips
|
||||
- `state` getter returns a defensive copy (immutable snapshot)
|
||||
- Standalone `vectorClockCompare(a, b)` for comparing arbitrary states without a clock instance
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { createVectorClock } from "@suaveplan/timing";
|
||||
|
||||
const a = createVectorClock({ nodeId: "A" });
|
||||
const b = createVectorClock({ nodeId: "B" });
|
||||
|
||||
a.increment(); // A: { A: 1 }
|
||||
b.update(a.toJSON()); // B: { A: 1, B: 0 }
|
||||
b.increment(); // B: { A: 1, B: 1 }
|
||||
|
||||
console.log(a.compare(b.toJSON())); // "before" — B has seen A's event
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { createVectorClock, vectorClockCompare } from "@suaveplan/timing";
|
||||
|
||||
// Three-node scenario
|
||||
const n1 = createVectorClock({ nodeId: "n1" });
|
||||
const n2 = createVectorClock({ nodeId: "n2" });
|
||||
const n3 = createVectorClock({ nodeId: "n3" });
|
||||
|
||||
n1.increment();
|
||||
n2.increment();
|
||||
|
||||
// n1 and n2 are concurrent (neither knows about the other)
|
||||
console.log(vectorClockCompare(n1.toJSON(), n2.toJSON())); // "concurrent"
|
||||
|
||||
// n3 merges both
|
||||
n3.update(n1.toJSON());
|
||||
n3.update(n2.toJSON());
|
||||
n3.increment();
|
||||
|
||||
// n3 is after both n1 and n2
|
||||
console.log(n3.compare(n1.toJSON())); // "after"
|
||||
console.log(n3.compare(n2.toJSON())); // "after"
|
||||
|
||||
// Round-trip serialization
|
||||
const snapshot = n3.toJSON();
|
||||
const n4 = createVectorClock({ nodeId: "n4" });
|
||||
n4.fromJSON(snapshot);
|
||||
console.log(n4.compare(snapshot)); // "equal"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createVectorClock(opts: VectorClockOptions): VectorClock`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `opts.nodeId` — the string ID of this node
|
||||
- `opts.initialState` — optional initial `Record<string, number>` state
|
||||
|
||||
**Returns:** `VectorClock`
|
||||
|
||||
---
|
||||
|
||||
### `vectorClockCompare(a: VectorClockState, b: VectorClockState): VectorClockRelation`
|
||||
|
||||
Compare two state snapshots without a clock instance. Returns `'before' | 'after' | 'concurrent' | 'equal'`.
|
||||
|
||||
---
|
||||
|
||||
### `VectorClock`
|
||||
|
||||
| Member | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `increment()` | `() => void` | Increment this node's counter. |
|
||||
| `update(remote)` | `(VectorClockState) => void` | Merge remote state (component-wise max). |
|
||||
| `compare(other)` | `(VectorClockState) => VectorClockRelation` | Compare local state to `other`. |
|
||||
| `toJSON()` | `() => VectorClockState` | Return a deep copy of current state. |
|
||||
| `fromJSON(raw)` | `(VectorClockState) => void` | Replace internal state from snapshot. |
|
||||
| `state` | `VectorClockState` (readonly) | Defensive copy of current state. |
|
||||
|
||||
---
|
||||
|
||||
### `VectorClockRelation`
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `'equal'` | All counters identical. |
|
||||
| `'before'` | `a` happened before `b` (a ≤ b component-wise, at least one strict). |
|
||||
| `'after'` | `a` happened after `b`. |
|
||||
| `'concurrent'` | Neither dominates. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Missing node keys are treated as `0` in all comparisons and merges, so clocks with different known-node sets can be compared without error. The `state` getter and `toJSON()` both return shallow copies (`{ ..._state }`) — since values are numbers (scalars), this is a deep copy for the purposes of this data structure. The `vectorClockCompare` function exits early as `'concurrent'` once it detects both `a < b` and `b < a` entries, giving O(n) best-case performance on divergent clocks.
|
||||
Reference in New Issue
Block a user