first commit

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

View File

@@ -0,0 +1,30 @@
# `flow`
Finite state machines, CRDTs, idempotency, and CBOR serialization.
<!-- begin:auto-docs -->
## Packages in `flow`
18 packages.
| Package | Description |
|---|---|
| [`@suaveplan/cbor`](./cbor/README.md) | RFC 8949 (CBOR) encoder + decoder, RFC 8742 (CBOR Sequences), pull-style streaming decoder (`AsyncIterable<Uint8Array>` / `ReadableStream<Uint8Array>`), RFC 90… |
| [`@suaveplan/crdt`](./crdt/README.md) | Conflict-free Replicated Data Types (CRDTs) for distributed systems. Provides eight composable CRDT primitives plus a vector-clock and a sync manager that coor… |
| [`@suaveplan/fsm`](./fsm/README.md) | Typed, generic finite state machine (FSM) for TypeScript. Ships a synchronous-by-default interpreter with guard predicates, ordered transition actions, context… |
| [`@suaveplan/idempotency`](./idempotency/README.md) | Idempotency key guard and deduplication for async operations. The package provides a complete `pending → completed \| failed` state machine, three store backend… |
| [`@suaveplan/job-scheduler`](./job-scheduler/README.md) | Universal in-process job scheduler with cron + queued runs, retries, timeouts, concurrency control, and OpenTelemetry observability. Built on `@suaveplan/cron`… |
| [`@suaveplan/queue`](./queue/README.md) | Enterprise-grade queue management: memory/priority/delayed queues, concurrent workers with retry and dead-letter forwarding, job state machines, and OTel instr… |
| [`@suaveplan/queue-distribution`](./queue-distribution/README.md) | Worker-pool distribution layer: load-balancing strategies, worker pool management, manager with retry and health checks, in-process `LocalWorker`. Transport-ag… |
| [`@suaveplan/queue-distribution-http`](./queue-distribution-http/README.md) | HTTP transport plugin for `@suaveplan/queue-distribution`. Ships `HttpWorkerCommunicator` (a fetch-backed `WorkerCommunicator` with a Zod-validated wire envelo… |
| [`@suaveplan/queue-distribution-worker-thread`](./queue-distribution-worker-thread/README.md) | Web-Worker / Node `worker_threads` transport plugin for `@suaveplan/queue-distribution`. Ships `WorkerThreadCommunicator` (a `ThreadWorkerLike`-based message b… |
| [`@suaveplan/rate-limiter`](./rate-limiter/README.md) | Enterprise rate limiter with three algorithms, rule-based routing, tenant multipliers, pluggable storage, and OTel instrumentation. |
| [`@suaveplan/retry`](./retry/README.md) | Retry strategies and circuit-breaker for async operations. The package provides constant, linear, and exponential backoff with four jitter modes (full, equal,… |
| [`@suaveplan/rules`](./rules/README.md) | Universal rule engine + validation system. Comprehensive `RuleEngine` with async evaluation, depth limit, AbortSignal, plugin registration; fluent `RuleBuilder… |
| [`@suaveplan/rules-hono`](./rules-hono/README.md) | Hono middleware wrapping `@suaveplan/rules`. Six middleware factories — `ruleMiddleware`, `validationMiddleware`, `accessControl`, `responseValidation`, `rateL… |
| [`@suaveplan/streams`](./streams/README.md) | WHATWG-Streams-based primitives: backpressure-first transforms, zero-copy `Uint8Array` operations, `AbortSignal` integration end-to-end, structured errors, OTe… |
| [`@suaveplan/streams-chunk-queue`](./streams-chunk-queue/README.md) | Universal out-of-order chunk reconstitution. Accepts chunks for a file in any order and exposes a sequential `ReadableStream<Uint8Array>` consumer surface, com… |
| [`@suaveplan/streams-metadata`](./streams-metadata/README.md) | Universal stream transforms that detect MIME type and extract EXIF/IPTC/XMP image metadata from buffered `Uint8Array` payloads. Three composable transforms (`M… |
| [`@suaveplan/undo`](./undo/README.md) | Enterprise-grade undo/redo engine with branching history, async commands, history compression, selective undo, and command merging. Built for collaborative edi… |
| [`@suaveplan/workflow`](./workflow/README.md) | Workflow and pipeline orchestration: sequential and parallel step execution, conditional branching with guard functions, retry strategies, timeouts, saga-patte… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Finite state machines, CRDTs, idempotency, and CBOR serialization.

View File

@@ -0,0 +1,183 @@
# @suaveplan/cbor - RFC 8949 CBOR encoder/decoder + RFC 8742 sequences + RFC 9052 COSE bridge + RFC 8392 CWT + RFC 8610 CDDL — zero browser/Node deps
## Summary
RFC 8949 (CBOR) encoder + decoder, RFC 8742 (CBOR Sequences), pull-style
streaming decoder (`AsyncIterable<Uint8Array>` / `ReadableStream<Uint8Array>`),
RFC 9052 §7 `COSE_Key` ↔ JWK round-trip + IANA algorithm bridge,
deterministic-encoding shortcut, RFC 8949 §8 diagnostic notation, tag
registry with IANA built-in handlers, and a strict §5.1 well-formedness
predicate. Universal — runs unmodified in Bun, Node, Deno, and the
browser. Zero browser/Node deps.
> **Wave 1 status.** This package is the foundational codec layer. Larger
> capabilities authored in the design proposal —
> COSE structures (RFC 9052 §4–§6), CWT (RFC 8392), CDDL (RFC 8610), and the
> `ChunkDecoder` partial-item streaming surface — are scoped IN and queued
> as follow-up waves on top of this codec.
<!-- begin:auto-docs -->
## Documentation tree
- [ast](./src/cddl/ast.md) — Typed AST shapes per RFC 8610 §3 — the contract between the parser and the validator (and any future code generator). E…
- [lexer](./src/cddl/lexer.md) — CDDL tokenizer per RFC 8610 §3 ABNF grammar. Reads a CDDL source string and emits a sequence of `Token` objects termina…
- [parser](./src/cddl/parser.md) — CDDL parser per RFC 8610 §3 grammar. Consumes the token stream from the `tokenize` function and produces a typed AST (`…
- [validate](./src/cddl/validate.md) — Walk a decoded CBOR value against a parsed CDDL schema and report the first mismatch (with a JSON-pointer-like path). T…
- [alg-map](./src/cose/alg-map.md) — Bridge between IANA COSE Algorithm identifiers (`CoseAlg` literal strings or the equivalent numeric labels from RFC 905…
- [cose-key](./src/cose/cose-key.md) — Decodes and encodes RFC 9052 §7 `COSE_Key` structures. The decoder maps an RFC-encoded CBOR map onto a typed discrimina…
- [encrypt](./src/cose/encrypt.md) — `COSE_Encrypt` (RFC 9052 §5.1) — multi-recipient AEAD-encrypted COSE message in CBOR tag 96. Each recipient holds its o…
- [encrypt0](./src/cose/encrypt0.md) — `COSE_Encrypt0` (RFC 9052 §5.2) — single-recipient AEAD-encrypted COSE message in CBOR tag 16. Wraps AES-256-GCM and Ch…
- [headers](./src/cose/headers.md) — Typed `CoseHeader` map shape covering RFC 9052 §3.1 + IANA COSE Header Parameters (alg, crit, content type, kid, IV, Pa…
- [jwk](./src/cose/jwk.md) — Round-trip converter between RFC 9052 `COSE_Key` instances and RFC 7517 JWK shapes. WebAuthn attestation flows often ne…
- [mac](./src/cose/mac.md) — `COSE_Mac` (RFC 9052 §6.1) — multi-recipient MAC message in CBOR tag 97. Each recipient gets a wrapped copy of the cont…
- [mac0](./src/cose/mac0.md) — `COSE_Mac0` (RFC 9052 §6.2) — single-recipient MAC message in CBOR tag 17. Used for symmetric-key authentication of CWT…
- [sig-structure](./src/cose/sig-structure.md) — Builders for the canonical input to COSE signature, MAC, and AEAD computations per RFC 9052 §4.4 / §6.3 / §5.3. Every s…
- [sign](./src/cose/sign.md) — `COSE_Sign` (RFC 9052 §4.1) — multi-signer COSE message in CBOR tag 98. Use when a payload needs to be co-signed by mul…
- [sign1](./src/cose/sign1.md) — `COSE_Sign1` (RFC 9052 §4.2) — single-signer COSE message in CBOR tag 18. WebAuthn attestation, COSE-signed firmware, a…
- [claims](./src/cwt/claims.md) — Encode and decode RFC 8392 CBOR Web Token claim sets. The seven IANA- registered claims (`iss`, `sub`, `aud`, `exp`, `n…
- [verify](./src/cwt/verify.md) — End-to-end CWT verification: parse the COSE wrapper, verify the signature or MAC, decode the claim set, and check the s…
- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - half-float — Decodes 2 big-endian bytes representin…
- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - half-float — Decodes 2 big-endian bytes representin…
- [half-float](./src/decoder/half-float.md) — Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript `number`. The decoder is always available — n…
- [well-formed](./src/decoder/well-formed.md) — `isWellFormed(bytes)` returns `true` iff `bytes` represent a single well-formed CBOR item per RFC 8949 §5.1. The functi…
- [deterministic](./src/deterministic/deterministic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [deterministic](./src/deterministic/deterministic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - half-float — Encodes a JavaScript `number` as IEEE……
- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - half-float — Encodes a JavaScript `number` as IEEE……
- [half-float](./src/encoder/half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sequences](./src/sequences/sequences.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sequences](./src/sequences/sequences.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [chunk-decoder](./src/streaming/chunk-decoder.md) — Partial-item streaming decoder. While `PullDecoder` yields one whole CBOR item at a time (waiting until each item is fu…
- [pull-decoder](./src/streaming/pull-decoder.md) — `PullDecoder` is a pull-style streaming decoder over an `AsyncIterable<Uint8Array>` or `ReadableStream<Uint8Array>`. Ea…
- [builtin](./src/tags/builtin.md) — Provides `defaultTagHandlers()`, a `TagRegistry` populated with handlers for the most-used IANA-registered CBOR tags. C…
- [registry](./src/tags/registry.md) — `TagRegistry` is the in-memory mutable container for CBOR tag handlers. The decoder consults a snapshot of the registry…
- [tagged](./src/tags/tagged.md) — Provides the `Tagged<T>` value object used by `@suaveplan/cbor` to materialise RFC 8949 §3.4 tagged items. The decoder…
<!-- end:auto-docs --> - half-float — Decodes 2 big-endian bytes representin…
- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- half-float — Decodes 2 big-endian bytes representin…
- [half-float](./src/decoder/half-float.md) — Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript `number`. The decoder is always available — n…
- [well-formed](./src/decoder/well-formed.md) — `isWellFormed(bytes)` returns `true` iff `bytes` represent a single well-formed CBOR item per RFC 8949 §5.1. The functi…
- [deterministic](./src/deterministic/deterministic.md) — <!-- begin:auto-docs --> _(no submodules)_- [deterministic](./src/deterministic/deterministic.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- half-float — Encodes a JavaScript `number` as IEEE……
- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- half-float — Encodes a JavaScript `number` as IEEE……
- [half-float](./src/encoder/half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [sequences](./src/sequences/sequences.md) — <!-- begin:auto-docs --> _(no submodules)_- [sequences](./src/sequences/sequences.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-decoder](./src/streaming/chunk-decoder.md) — Partial-item streaming decoder. While `PullDecoder` yields one whole CBOR item at a time (waiting until each item is fu…
- [pull-decoder](./src/streaming/pull-decoder.md) — `PullDecoder` is a pull-style streaming decoder over an `AsyncIterable<Uint8Array>` or `ReadableStream<Uint8Array>`. Ea…
- [builtin](./src/tags/builtin.md) — Provides `defaultTagHandlers()`, a `TagRegistry` populated with handlers for the most-used IANA-registered CBOR tags. C…
- [registry](./src/tags/registry.md) — `TagRegistry` is the in-memory mutable container for CBOR tag handlers. The decoder consults a snapshot of the registry…
- [tagged](./src/tags/tagged.md) — Provides the `Tagged<T>` value object used by `@suaveplan/cbor` to materialise RFC 8949 §3.4 tagged items. The decoder…
- half-float — Decodes 2 big-endian bytes representin…
- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- half-float — Decodes 2 big-endian bytes representin…
- [half-float](./src/decoder/half-float.md) — Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript `number`. The decoder is always available — n…
- [well-formed](./src/decoder/well-formed.md) — `isWellFormed(bytes)` returns `true` iff `bytes` represent a single well-formed CBOR item per RFC 8949 §5.1. The functi…
- [deterministic](./src/deterministic/deterministic.md) — <!-- begin:auto-docs --> _(no submodules)_- [deterministic](./src/deterministic/deterministic.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- half-float — Encodes a JavaScript `number` as IEEE……
- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- half-float — Encodes a JavaScript `number` as IEEE……
- [half-float](./src/encoder/half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [sequences](./src/sequences/sequences.md) — <!-- begin:auto-docs --> _(no submodules)_- [sequences](./src/sequences/sequences.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-decoder](./src/streaming/chunk-decoder.md) — Partial-item streaming decoder. While `PullDecoder` yields one whole CBOR item at a time (waiting until each item is fu…
- [pull-decoder](./src/streaming/pull-decoder.md) — `PullDecoder` is a pull-style streaming decoder over an `AsyncIterable<Uint8Array>` or `ReadableStream<Uint8Array>`. Ea…
- [builtin](./src/tags/builtin.md) — Provides `defaultTagHandlers()`, a `TagRegistry` populated with handlers for the most-used IANA-registered CBOR tags. C…
- [registry](./src/tags/registry.md) — `TagRegistry` is the in-memory mutable container for CBOR tag handlers. The decoder consults a snapshot of the registry…
- [tagged](./src/tags/tagged.md) — Provides the `Tagged<T>` value object used by `@suaveplan/cbor` to materialise RFC 8949 §3.4 tagged items. The decoder…
- half-float — Decodes 2 big-endian bytes representin…
- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- half-float — Decodes 2 big-endian bytes representin…
- [half-float](./src/decoder/half-float.md) — Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript `number`. The decoder is always available — n…
- [well-formed](./src/decoder/well-formed.md) — `isWellFormed(bytes)` returns `true` iff `bytes` represent a single well-formed CBOR item per RFC 8949 §5.1. The functi…
- [deterministic](./src/deterministic/deterministic.md) — <!-- begin:auto-docs --> _(no submodules)_- [deterministic](./src/deterministic/deterministic.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- half-float — Encodes a JavaScript `number` as IEEE…
- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- half-float — Encodes a JavaScript `number` as IEEE…
- [half-float](./src/encoder/half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [sequences](./src/sequences/sequences.md) — <!-- begin:auto-docs --> _(no submodules)_- [sequences](./src/sequences/sequences.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-decoder](./src/streaming/chunk-decoder.md) — Partial-item streaming decoder. While `PullDecoder` yields one whole CBOR item at a time (waiting until each item is fu…
- [pull-decoder](./src/streaming/pull-decoder.md) — `PullDecoder` is a pull-style streaming decoder over an `AsyncIterable<Uint8Array>` or `ReadableStream<Uint8Array>`. Ea…
- [builtin](./src/tags/builtin.md) — Provides `defaultTagHandlers()`, a `TagRegistry` populated with handlers for the most-used IANA-registered CBOR tags. C…
- [registry](./src/tags/registry.md) — `TagRegistry` is the in-memory mutable container for CBOR tag handlers. The decoder consults a snapshot of the registry…
- [tagged](./src/tags/tagged.md) — Provides the `Tagged<T>` value object used by `@suaveplan/cbor` to materialise RFC 8949 §3.4 tagged items. The decoder…
- [decoder](./src/decoder/decoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [half-float](./src/decoder/half-float.md) — Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript `number`. The decoder is always available — n…
- [well-formed](./src/decoder/well-formed.md) — `isWellFormed(bytes)` returns `true` iff `bytes` represent a single well-formed CBOR item per RFC 8949 §5.1. The functi…
- [deterministic](./src/deterministic/deterministic.md) — <!-- begin:auto-docs --> _(no submodules)_ - [deterministic](./src/deterministic/deterministic.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_ - [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_ - [encoder](./src/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_ - [half-float](./src/encoder/half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ - [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [sequences](./src/sequences/sequences.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sequences](./src/sequences/sequences.md) — <!-- begin:auto-docs --> _(no submodules)_- [chunk-decoder](./src/streaming/chunk-decoder.md) — Partial-item streaming decoder. While `PullDecoder` yields one whole CBOR item at a time (waiting until each item is fu…
- [pull-decoder](./src/streaming/pull-decoder.md) — `PullDecoder` is a pull-style streaming decoder over an `AsyncIterable<Uint8Array>` or `ReadableStream<Uint8Array>`. Ea…
- [builtin](./src/tags/builtin.md) — Provides `defaultTagHandlers()`, a `TagRegistry` populated with handlers for the most-used IANA-registered CBOR tags. C…
- [registry](./src/tags/registry.md) — `TagRegistry` is the in-memory mutable container for CBOR tag handlers. The decoder consults a snapshot of the registry…
- [tagged](./src/tags/tagged.md) — Provides the `Tagged<T>` value object used by `@suaveplan/cbor` to materialise RFC 8949 §3.4 tagged items. The decoder…
## Install
```bash
bun add @suaveplan/cbor
```
## Quick Start
```ts
import { encode, decode } from "@suaveplan/cbor";
const bytes = encode({ a: 1, b: [true, "two"] });
const value = decode(bytes);
```
## Modules
| Module | Doc |
|---|---|
| `encoder` | [`src/encoder/encoder.md`](src/encoder/encoder.md) |
| `encoder/half-float` | [`src/encoder/half-float.md`](src/encoder/half-float.md) |
| `decoder` | [`src/decoder/decoder.md`](src/decoder/decoder.md) |
| `decoder/half-float` | [`src/decoder/half-float.md`](src/decoder/half-float.md) |
| `decoder/well-formed` | [`src/decoder/well-formed.md`](src/decoder/well-formed.md) |
| `streaming/pull-decoder` | [`src/streaming/pull-decoder.md`](src/streaming/pull-decoder.md) |
| `deterministic` | [`src/deterministic/deterministic.md`](src/deterministic/deterministic.md) |
| `diagnostic` | [`src/diagnostic/diagnostic.md`](src/diagnostic/diagnostic.md) |
| `cose/cose-key` | [`src/cose/cose-key.md`](src/cose/cose-key.md) |
| `cose/jwk` | [`src/cose/jwk.md`](src/cose/jwk.md) |
| `cose/alg-map` | [`src/cose/alg-map.md`](src/cose/alg-map.md) |
| `cose/headers` | [`src/cose/headers.md`](src/cose/headers.md) |
| `cose/sig-structure` | [`src/cose/sig-structure.md`](src/cose/sig-structure.md) |
| `cose/sign1` | [`src/cose/sign1.md`](src/cose/sign1.md) |
| `cose/mac0` | [`src/cose/mac0.md`](src/cose/mac0.md) |
| `cwt/claims` | [`src/cwt/claims.md`](src/cwt/claims.md) |
| `cwt/verify` | [`src/cwt/verify.md`](src/cwt/verify.md) |
| `streaming/chunk-decoder` | [`src/streaming/chunk-decoder.md`](src/streaming/chunk-decoder.md) |
| `cose/sign` | [`src/cose/sign.md`](src/cose/sign.md) |
| `cose/mac` | [`src/cose/mac.md`](src/cose/mac.md) |
| `cose/encrypt0` | [`src/cose/encrypt0.md`](src/cose/encrypt0.md) |
| `cose/encrypt` | [`src/cose/encrypt.md`](src/cose/encrypt.md) |
| `tags/registry` | [`src/tags/registry.md`](src/tags/registry.md) |
| `tags/tagged` | [`src/tags/tagged.md`](src/tags/tagged.md) |
| `tags/builtin` | [`src/tags/builtin.md`](src/tags/builtin.md) |
| `sequences` | [`src/sequences/sequences.md`](src/sequences/sequences.md) |
| `errors` | [`src/errors/error-codes.md`](src/errors/error-codes.md) |
## Subpath exports
- `@suaveplan/cbor` — root barrel: `encode`, `decode`, `Tagged`, error codes
- `@suaveplan/cbor/encoder` — encoder + half-float
- `@suaveplan/cbor/decoder` — decoder + half-float + `isWellFormed`
- `@suaveplan/cbor/streaming` — `PullDecoder` over async-iterable / WHATWG streams
- `@suaveplan/cbor/sequences` — RFC 8742
- `@suaveplan/cbor/deterministic` — `encodeDeterministic` shortcut for §4.2
- `@suaveplan/cbor/diagnostic` — RFC 8949 §8 notation emitter
- `@suaveplan/cbor/cose` — `COSE_Key` ↔ JWK + IANA algorithm bridge (RFC 9052 §7), Sig_structure builders (§4.4 / §6.3 / §5.3), `COSE_Sign1` encode/decode/verify (§4.2, ES256/EdDSA), `COSE_Mac0` create/encode/decode/verify (§6.2, HS256/384/512)
- `@suaveplan/cbor/cwt` — RFC 8392 CBOR Web Token claim encode/decode + `verifyCwtSign1` / `verifyCwtMac0` with time-window + audience + issuer validation
- `@suaveplan/cbor/cddl` — RFC 8610 CDDL lexer + parser + AST + validator (primitive prelude, ranges, choices, arrays, maps, occurrence indicators, `.size`/`.regexp`/`.cbor` control operators, generics, named references)
- `@suaveplan/cbor/tags` — `Tagged`, `TagRegistry`, `defaultTagHandlers`
- `@suaveplan/cbor/errors` — `CBOR_CODES`, `CborError`, `CborErrorCode`
## Conformance
- RFC 8949 §3.1 — major types 0–7 encode + decode
- RFC 8949 §3.2 — indefinite-length text/byte strings, arrays, maps (decoder)
- RFC 8949 §3.3 — IEEE 754 binary16 encode (opt-in) + decode (always-on)
- RFC 8949 §3.4 — tagged-item registry
- RFC 8949 §4.1 — preferred serialization (shortest-form integers always-on,
shortest-exact float widths)
- RFC 8949 §4.2 — deterministic-encoding profile (sorted map keys, no
indefinite-length, half-float disabled in deterministic mode)
- RFC 8949 §5.1 — strict-mode well-formedness; every malformed-input class
raises `CborError(MALFORMED)`
- RFC 8742 §2 + §3 — CBOR Sequences encode + decode + async-iterator stream

View File

@@ -0,0 +1,58 @@
# `ast`
## Purpose
Typed AST shapes per RFC 8610 §3 — the contract between the parser and
the validator (and any future code generator). Every CDDL syntactic
construct lands as a discriminated-union node so consumers can pattern-match
exhaustively.
## Features
- `CddlAst` — root: `{ rules: CddlRule[] }`.
- `CddlRule` — `{ name, generics, type }`.
- `CddlType` — discriminated union over: primitives, named refs (with
generic args), literal values (text / byte / int / uint / bool / null /
undefined), ranges, choice, arrays, maps, tagged items, control operators.
- `CddlGroupEntry` — an array/map entry with `occurrence`, optional `key`,
and a `type`.
- `CddlGroupKey` — text / uint / int. (Typed keys are reserved for a
future parser extension.)
- `CddlOccurrence` — `exact` / `optional` / `zero-or-more` / `one-or-more` /
`range`.
- `PrimitiveName` + `PRIMITIVE_NAMES` — frozen set of every recognised
prelude type from RFC 8610 §3.8.
## Usage: Basic Example
```ts
import type { CddlAst, CddlRule } from "@suaveplan/cbor/cddl";
function ruleNames(ast: CddlAst): string[] {
return ast.rules.map((r: CddlRule) => r.name);
}
```
## Usage: Advanced Example
```ts
import type { CddlType } from "@suaveplan/cbor/cddl";
function isOptionalString(t: CddlType): boolean {
return t.kind === "primitive" && t.name === "tstr";
}
```
## API Reference
See the type definitions in `ast.ts`. The AST is intentionally pure data
— no methods, no mutable state — so it round-trips through serialisation
without surprises.
## Implementation Notes
The AST is the public contract for downstream consumers (validators, code
generators, schema-based fuzzers). Adding a new node kind requires updating
both the parser (to produce it) and every walker (to consume it). The
`PRIMITIVE_NAMES` set is exported so consumers writing their own walkers
can recognise the prelude types without duplicating the literal union.

View File

@@ -0,0 +1,69 @@
# `lexer`
## Purpose
CDDL tokenizer per RFC 8610 §3 ABNF grammar. Reads a CDDL source string and
emits a sequence of `Token` objects terminated by an `eof` sentinel. Throws
`CborError(CDDL_PARSE_FAILED)` with `{ line, col }` context on lexical
errors so callers can surface precise diagnostics.
## Features
- Full token coverage for the subset the parser supports:
- Identifiers (with `_`/`@`/`-` continuation)
- Integer literals (`42`, `-7`, `0`)
- Quoted-string literals with `\n` / `\r` / `\t` / `\\` / `\"` escapes
- Hex byte-string literals `h'deadbeef'` (whitespace-tolerant)
- Punctuation: `=`, `/`, `//`, `*`, `+`, `?`, `..`, `...`, `[`, `]`,
`{`, `}`, `(`, `)`, `<`, `>`, `,`, `:`, `=>`, `#`, `.`
- Control operators (`.size`, `.regexp`, `.cbor`, `.within`, `.and`,
`.lt`, `.le`, `.gt`, `.ge`, `.eq`, `.ne`, `.default`, `.bits`)
- Line-comment skip via `;`
- Line + column tracking — every token carries its source position for
precise error messages downstream.
- CDDL float literals are deliberately not lexed: they would conflict with
the tagged-item syntax `#6.<n>(T)` (where `6.1` would otherwise lex as a
float). Authors needing float values use the `float` / `float32` / `float64`
primitive types instead.
## Usage: Basic Example
```ts
import { tokenize } from "@suaveplan/cbor/cddl";
const tokens = tokenize("foo = uint");
// [{ kind: "identifier", value: "foo", line: 1, col: 1 },
// { kind: "=", ... }, { kind: "identifier", value: "uint", ... },
// { kind: "eof", ... }]
```
## Usage: Advanced Example
```ts
import { tokenize } from "@suaveplan/cbor/cddl";
try {
tokenize("foo % bar");
} catch (e) {
// CborError(CDDL_PARSE_FAILED) with line/col context
}
```
## API Reference
- `tokenize(source: string): Token[]`
- `interface Token { kind: TokenKind; value: string | number | Uint8Array; line: number; col: number }`
- `type TokenKind` — literal union of every emitted token kind.
Throws `CborError(CDDL_PARSE_FAILED)` for: unterminated string / byte string,
unknown escape sequence, invalid hex byte string, odd-length hex literal,
unexpected character.
## Implementation Notes
The lexer is a single-pass recursive-descent character scanner. Multi-char
punctuation (`//`, `=>`, `..`, `...`) is matched ahead of single-char so
`/` versus `//` and `.` versus `..` versus `...` discriminate cleanly.
Identifiers never include `.` — this keeps `0..255` and `tstr .size`
parsing correctly without backtracking. Comments are skipped at the top
level of the loop.

View File

@@ -0,0 +1,71 @@
# `parser`
## Purpose
CDDL parser per RFC 8610 §3 grammar. Consumes the token stream from the
`tokenize` function and produces a typed AST (`CddlAst`) that the validator
walks. Supports the working subset of CDDL needed by real consumers (CWT,
COSE_Sign1, WebAuthn attestation, vault manifests, drive templates).
## Features
- Rule definitions: `name = T`, `name<T> = ...` (generic params).
- Type expressions: primitive prelude, named references, generic instantiation.
- Type composition: choice (`/`), inclusive (`..`) + exclusive (`...`) ranges,
parenthesised grouping.
- Group composition: arrays (`[ ... ]`), maps (`{ ... }`), occurrence
indicators (`?`, `*`, `+`, `*N`, `M*N`, `M*`), group choice (`//`).
- Map keys: identifier-`:`-T (text key), `"quoted":` T (text key with quoted
syntax), `<int> => T` (numeric key), `"quoted" => T`.
- Tagged items: `#6.<tag>(T)` produces `{ kind: "tagged", tag, inner }`.
- Literal values: text strings, byte strings (`h'…'`), integers (positive and
negative), `true`/`false`, `null`/`nil`, `undefined`.
- Control operators applied to types: `tstr .size 1..32`, `tstr .regexp "pattern"`,
`bstr .cbor T`. Both literal and range right-hand sides supported on `.size`.
## Usage: Basic Example
```ts
import { parseCddl } from "@suaveplan/cbor/cddl";
const ast = parseCddl(`
cwt = { iss: tstr, ? exp: int }
`);
```
## Usage: Advanced Example
```ts
import { parseCddl } from "@suaveplan/cbor/cddl";
const ast = parseCddl(`
attested-key = #6.18(cose-sign1)
cose-sign1 = [ protected: bstr, unprotected: { * any => any }, payload: bstr / nil, signature: bstr ]
protected = bstr .size 0..1024
pair<K, V> = [ K, V ]
`);
```
## API Reference
- `parseCddl(source: string): CddlAst`
Throws `CborError(CDDL_PARSE_FAILED)` with `{ line, col }` for: empty
source (no rules), missing `=`, unexpected token, non-integer range bound,
`#N` with N != 6.
## Implementation Notes
The parser is a hand-written recursive descent. Operator precedence
mirrors RFC 8610 §3:
- `type` (top): handles `/` choice
- `type1`: handles `..` / `...` ranges and control operators (`.size`,
`.regexp`, etc.); the `.size` case can take a range right-hand side
- `type2` (atom): primitives, references, literals, parenthesised groups,
arrays, maps, tagged items
Group choice (`//`) is implemented at the `[`/`{` body level — when the
parser encounters `//` between two sets of entries, it returns a
`{ kind: "choice", alternatives: [arrayA, arrayB] }` so the validator
can short-circuit on the first matching alternative.

View File

@@ -0,0 +1,84 @@
# `validate`
## Purpose
Walk a decoded CBOR value against a parsed CDDL schema and report the first
mismatch (with a JSON-pointer-like path). The validator is the run-time
counterpart to the parser — given a typed AST and a value, return
`{ valid, errors }` describing whether the value conforms.
## Features
- All primitive prelude types: `uint`, `nint`, `int`, `bstr`, `tstr`, `bool`,
`nil`, `null`, `undefined`, `any`, `number`, `text`, `bytes`, `float` /
`float16` / `float32` / `float64`, `tdate` (RFC 3339 string), `time`
(number or bigint epoch).
- Literal-value matching: text, byte, integer (signed and unsigned),
bool, null, undefined.
- Range types: inclusive (`..`) and exclusive (`...`).
- Choice: tries each alternative in isolation; first match wins.
- Arrays: positional `exact`-occurrence entries, optional, one-or-more,
zero-or-more, and `M*N` ranges. Extra entries past the schema are rejected.
- Maps: text and integer keys; required (`exact`) and optional (`?`) keys;
reports the missing key in error messages.
- Tagged items via `#6.<n>(T)` parse syntax → typed `{ kind: "tagged" }` AST.
- Named references: walks `name = T` rules transparently; entry rule is
selectable via `options.rule`.
- Control operators: `.size <int|range>` (string or bytes length),
`.regexp "pattern"` (RegExp match on text), `.cbor T` (left side is bytes).
Unknown control operators pass through (treated as informational
rather than fail-closed).
## Usage: Basic Example
```ts
import { validateCbor } from "@suaveplan/cbor/cddl";
const r = validateCbor({ iss: "alice", exp: 1700000000 }, `
cwt = { iss: tstr, ? exp: int }
`);
console.log(r.valid); // true
```
## Usage: Advanced Example
```ts
import { parseCddl, validateCbor } from "@suaveplan/cbor/cddl";
const schema = parseCddl(`
message = #6.18(cose-sign1)
cose-sign1 = [ bstr, { * any => any }, bstr / nil, bstr ]
`);
import { decode } from "@suaveplan/cbor";
import { Tagged } from "@suaveplan/cbor/tags";
const value = decode(wireBytes, { tagHandling: "raw" });
const r = validateCbor(value, schema, { rule: "message" });
if (!r.valid) {
for (const err of r.errors) {
console.error(`at ${err.path.join(".")}: expected ${err.expected}, got ${err.got}`);
}
}
```
## API Reference
- `validateCbor(value: unknown, schema: CddlAst | string, options?: { rule?: string }): CddlValidationResult`
- `interface CddlValidationResult { valid: boolean; errors: CddlValidationError[] }`
- `interface CddlValidationError { path: (string | number)[]; expected: string; got: string }`
Throws `CborError(CDDL_RULE_NOT_FOUND)` if `options.rule` names a rule
that doesn't exist; throws `CborError(CDDL_UNDEFINED_REFERENCE)` if a rule
body references an undefined name.
## Implementation Notes
The walk is a recursive AST switch dispatching on `type.kind`. Choice
validation runs each alternative against a fresh sub-context — the first
alternative whose walk produces zero errors wins. Map validation accepts
both `Map` instances and plain objects; the latter is wrapped via
`Object.entries` for uniform key lookup. Array entries are matched
greedily within their occurrence-range bound — no backtracking — which
is correct for the schemas this package targets but a future extension
could add limited backtracking for more permissive schemas.

View File

@@ -0,0 +1,67 @@
# `alg-map`
## Purpose
Bridge between IANA COSE Algorithm identifiers (`CoseAlg` literal strings or
the equivalent numeric labels from RFC 9053 §2) and the suaveplan-internal
algorithm identifiers consumed by `@suaveplan/crypto-curve`,
`@suaveplan/crypto-cipher`, and `@suaveplan/crypto-hash`. The bridge is the
single point where COSE / CWT / WebAuthn flows decide *which* suaveplan
primitive will run for a given algorithm label.
## Features
- Full IANA registry coverage for the algorithms the crypto packages
ship: ES256, ES384, ES512, EdDSA (Ed25519), RS256, RS384, RS512, HS256,
HS384, HS512, A128GCM, A192GCM, A256GCM, ChaCha20Poly1305.
- Accepts both the typed `CoseAlg` string and the raw IANA numeric label.
- Reverse mapping (`cryptoAlgToCoseAlg`) for outbound encoding.
- Throws `CborError(UNEXPECTED_TYPE)` for any algorithm the bridge does
not implement — this is the intentional point of failure when a caller
tries to use a primitive suaveplan does not (yet) ship.
## Usage: Basic Example
```ts
import { coseAlgToCryptoAlg } from "@suaveplan/cbor/cose";
const alg = coseAlgToCryptoAlg("ES256"); // "ecdsa-p256-sha256"
```
## Usage: Advanced Example
```ts
import { coseAlgToCryptoAlg, decodeCoseKey } from "@suaveplan/cbor/cose";
// CWT verification dispatch
const key = decodeCoseKey(keyBytes);
const cryptoAlg = coseAlgToCryptoAlg(key.alg ?? "ES256");
switch (cryptoAlg) {
case "ecdsa-p256-sha256":
// hand off to @suaveplan/crypto-curve.p256.verify
break;
case "ed25519":
// hand off to @suaveplan/crypto-curve.ed25519.verify
break;
// ...
}
```
## API Reference
- `coseAlgToCryptoAlg(alg: CoseAlg | number): suaveplanCryptoAlg`
- `cryptoAlgToCoseAlg(alg: suaveplanCryptoAlg): CoseAlg`
- `type suaveplanCryptoAlg` — literal-typed union of the suaveplan-internal
algorithm IDs the bridge resolves to.
Throws `CborError(UNEXPECTED_TYPE)` for unknown numeric labels and for
algorithm strings outside the supported set.
## Implementation Notes
The bridge is a static lookup table — adding a new algorithm requires a
single new entry in both the `CoseAlg` literal type and the `ALG_BRIDGE`
record, plus a corresponding entry in the IANA-driven `COSE_ALG_LABELS`.
Keeping the conversion in a separate module (rather than threading raw
strings through the codec) ensures every inbound algorithm label crosses
exactly one validation boundary before reaching the cryptographic core.

View File

@@ -0,0 +1,71 @@
# `cose-key`
## Purpose
Decodes and encodes RFC 9052 §7 `COSE_Key` structures. The decoder maps an
RFC-encoded CBOR map onto a typed discriminated union (`Ec2Key | OkpKey |
RsaKey | SymmetricKey`); the encoder reverses the mapping with deterministic
encoding so two keys with identical fields always produce byte-identical
output. WebAuthn attestation public keys, COSE_Sign1 signing keys, and CWT
verification keys all flow through this module.
## Features
- Discriminated union by `kty` — TypeScript narrows on `key.kty === "EC2"`
etc. without explicit casts.
- Per-key-type field validation: EC2 (`crv` + `x` + `y` + optional `d`),
OKP (`crv` + `x` + optional `d`), RSA (`n` + `e` + optional `d`/`p`/`q`/
`dP`/`dQ`/`qInv`), Symmetric (`k`).
- Common fields (`alg`, `kid`, `key_ops`, `baseIV`) parsed once and applied
uniformly across all key types.
- Throws `CborError(UNEXPECTED_TYPE)` for malformed maps, unknown labels,
wrong inner types, and missing required fields.
- Encoder uses RFC 8949 §4.2 deterministic encoding so the byte output is
stable across runs — required for COSE structures that include the key
bytes in signed/verified payloads.
## Usage: Basic Example
```ts
import { decodeCoseKey, encodeCoseKey } from "@suaveplan/cbor/cose";
const key = decodeCoseKey(bytes);
if (key.kty === "EC2") {
console.log("ES256 public key", key.x, key.y);
}
const wire = encodeCoseKey(key);
```
## Usage: Advanced Example
```ts
import { decodeCoseKey } from "@suaveplan/cbor/cose";
import { coseAlgToCryptoAlg } from "@suaveplan/cbor/cose";
// WebAuthn attestation flow
const attestationKey = decodeCoseKey(authData.credentialPublicKey);
if (attestationKey.kty !== "EC2") {
throw new Error("expected EC2 attestation key");
}
const cryptoAlg = coseAlgToCryptoAlg(attestationKey.alg ?? "ES256");
// Hand off to @suaveplan/crypto-curve to verify the signature.
```
## API Reference
- `decodeCoseKey(bytes: Uint8Array): CoseKey` — decode a CBOR-encoded
`COSE_Key` byte string into the typed union. Throws `CborError` for any
structural defect.
- `encodeCoseKey(key: CoseKey): Uint8Array` — deterministic-encode the key.
- See `cose.types.ts` for `CoseKey`, `Ec2Key`, `OkpKey`, `RsaKey`,
`SymmetricKey`, `CoseAlg`, `CoseCurve`, `CoseKeyOp`.
## Implementation Notes
The decoder tolerates extra map entries — RFC 9052 §7.1 explicitly permits
forward-compatible additions, and rejecting unknown labels would break
forward compatibility. The encoder, by contrast, emits only the fields
defined in the typed shape so it cannot accidentally leak extension data
that was never modelled. Common fields are read once into a small `CommonOut`
record and spread into every key shape — this avoids drift between the four
per-kty readers when a new common field is added in the future.

View File

@@ -0,0 +1,55 @@
# `encrypt`
## Purpose
`COSE_Encrypt` (RFC 9052 §5.1) — multi-recipient AEAD-encrypted COSE
message in CBOR tag 96. Each recipient holds its own wrapped copy of the
content-encryption key (CEK); the body carries one ciphertext encrypted
under the CEK. This module ships encode/decode of the wire shape plus a
kid-based recipient lookup.
## Features
- `encodeCoseEncrypt` builds the 4-tuple `[bodyProtected, bodyUnprotected,
ciphertext, recipients]`. Per-recipient triples
`[recipientProtected, recipientUnprotected, encryptedKey]` carry the
wrapped-CEK bytes verbatim — KEK wrap is delegated to whatever scheme
the recipient declares.
- `decodeCoseEncrypt` recovers the typed view including each recipient's
`protectedRaw` for any per-recipient verification flow.
- `findEncryptRecipientByKid` does a `kid` byte compare across all
recipients (looking in protected then unprotected headers).
- Empty recipient arrays are rejected per §5.1.
## Usage
```ts
import { encodeCoseEncrypt, decodeCoseEncrypt, findEncryptRecipientByKid } from "@suaveplan/cbor/cose";
const wire = encodeCoseEncrypt({
protectedHeader: { alg: "A256GCM" },
unprotectedHeader: { iv },
ciphertext: aeadOutput,
recipients: [
{ unprotectedHeader: { kid: kidA }, encryptedKey: wrappedCekA },
{ unprotectedHeader: { kid: kidB }, encryptedKey: wrappedCekB },
],
});
const msg = decodeCoseEncrypt(wire);
const mine = findEncryptRecipientByKid(msg, kidA);
```
## API
- `encodeCoseEncrypt(input)` / `decodeCoseEncrypt(bytes)`.
- `findEncryptRecipientByKid(msg, kid): CoseRecipient | undefined`.
- `COSE_ENCRYPT_TAG = 96`.
## Notes
The CEK wrapping is handled by the caller — this module is the wire-shape
codec. Common KEK schemes (`A128KW`, `A256KW`, `direct`, `ECDH-ES`,
`ECDH-ES+A256KW`) all serialise into the recipient bucket the same way:
the wrapped key bytes go in slot 2, the algorithm declaration goes in the
recipient's protected header.

View File

@@ -0,0 +1,54 @@
# `encrypt0`
## Purpose
`COSE_Encrypt0` (RFC 9052 §5.2) — single-recipient AEAD-encrypted COSE
message in CBOR tag 16. Wraps AES-256-GCM and ChaCha20-Poly1305 from
`@suaveplan/crypto-cipher` and binds the ciphertext to the canonical
`Enc_structure` (§5.3) so external AAD propagates through the AEAD
authenticator.
## Features
- `encryptCoseEncrypt0` performs the AEAD computation and returns a fully-
formed COSE message; the IV automatically lands in the unprotected header
unless the caller already placed it there.
- `decryptCoseEncrypt0` resolves the IV from either the unprotected or
protected header, recomputes `Enc_structure`, and authenticates plus
decrypts in one call. Tampered ciphertext / wrong key / wrong AAD all
raise `CborError(COSE_AEAD_FAILED)`.
- Tagged + tagless wire forms supported.
- AES-256-GCM and ChaCha20-Poly1305 covered; ES384/ES512/AES-128-GCM/etc.
are explicit "not supported" errors so the gap is visible.
## Usage
```ts
import { encryptCoseEncrypt0, decryptCoseEncrypt0, decodeCoseEncrypt0 } from "@suaveplan/cbor/cose";
const wire = encryptCoseEncrypt0({
protectedHeader: { alg: "A256GCM" },
plaintext,
key,
iv,
});
const msg = decodeCoseEncrypt0(wire);
const back = decryptCoseEncrypt0(msg, key);
```
## API
- `encryptCoseEncrypt0(input)` / `encodeCoseEncrypt0(input)`.
- `decodeCoseEncrypt0(bytes): CoseEncrypt0`.
- `decryptCoseEncrypt0(msg, key, options?): Uint8Array` — throws
`CborError(COSE_AEAD_FAILED)` on auth failure.
- `COSE_ENCRYPT0_TAG = 16`.
## Notes
The AEAD primitive's AAD argument receives the canonical `Enc_structure`
bytes — RFC 9052 §5.3 dictates this binding so an attacker cannot replay
ciphertext under a different protected header. External AAD adds a second
layer of context (e.g. transaction-id or session-id) and is mixed in as the
third element of the structure before encoding.

View File

@@ -0,0 +1,43 @@
# `headers`
## Purpose
Typed `CoseHeader` map shape covering RFC 9052 §3.1 + IANA COSE Header
Parameters (alg, crit, content type, kid, IV, Partial IV, counter signature).
Two helpers — `mapToHeader` and `headerToMap` — round-trip between the typed
shape and the plain CBOR map representation that lands on the wire.
## Features
- `alg` is typed as `CoseAlg` so the algorithm union narrows downstream.
- `crit` rejects empty arrays and non-numeric labels per §3.1.
- `contentType` accepts both numeric (IANA CoAP Content-Format) and string
(RFC 6838) forms.
- All byte-string fields validated at the boundary; non-bytes throws
`COSE_HEADER_INVALID`.
- Round-trip is total: every field in the typed shape lands on the wire and
vice versa, with no implicit drops.
## Usage
```ts
import { headerToMap, mapToHeader } from "@suaveplan/cbor/cose";
import { encode, decode } from "@suaveplan/cbor";
const m = headerToMap({ alg: "ES256", kid: new Uint8Array([0xab]) });
const wire = encode(m, { deterministic: true });
const back = mapToHeader(decode(wire) as Map<unknown, unknown>);
```
## API
- `interface CoseHeader` — typed shape.
- `mapToHeader(m: Map<unknown, unknown>): CoseHeader`.
- `headerToMap(h: CoseHeader): Map<number, unknown>`.
## Notes
`crit` must be non-empty per §3.1: an empty list is malformed because it
implies "every header is unsupported", which contradicts the receiver's
ability to verify the message. The guard catches encoder bugs that would
otherwise emit `[]` for a header set with no critical entries.

View File

@@ -0,0 +1,70 @@
# `jwk`
## Purpose
Round-trip converter between RFC 9052 `COSE_Key` instances and RFC 7517 JWK
shapes. WebAuthn attestation flows often need to hand a public key to a
WebCrypto-style verifier that consumes JWK; conversely, server-issued
JWK-formatted keys may need to land in a CBOR-encoded COSE structure. The
two conversions preserve every field that has a defined cross-format
mapping.
## Features
- `coseKeyToJwk(key)` — accepts any `CoseKey` discriminant (EC2, OKP, RSA,
Symmetric) and emits the matching JWK (`EC`, `OKP`, `RSA`, `oct`).
- `jwkToCoseKey(jwk)` — reverse mapping with full byte-string round-trip.
- Byte-string fields use RFC 4648 §5 base64url encoding without padding.
- `kid` is treated as UTF-8 bytes on the COSE side and a string on the JWK
side; conversion is lossy only if a non-UTF-8 kid is in play (rare, and
RFC 9052 §3.1 documents the convention).
- `alg` round-trips through a fixed COSE↔JWK label table so the typed
algorithm lands consistently on either side.
## Usage: Basic Example
```ts
import { coseKeyToJwk, jwkToCoseKey } from "@suaveplan/cbor/cose";
const jwk = coseKeyToJwk(coseKey);
const back = jwkToCoseKey(jwk);
```
## Usage: Advanced Example
```ts
import { decodeCoseKey, coseKeyToJwk } from "@suaveplan/cbor/cose";
// Attestation key from CTAP2
const cose = decodeCoseKey(attestationKeyBytes);
const jwk = coseKeyToJwk(cose);
// Hand off to WebCrypto
const cryptoKey = await crypto.subtle.importKey(
"jwk",
jwk,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"],
);
```
## API Reference
- `coseKeyToJwk(key: CoseKey): Jwk` — discriminator-aware conversion to JWK.
- `jwkToCoseKey(jwk: Jwk): CoseKey` — reverse conversion.
- `Jwk = Ec2Jwk | OkpJwk | RsaJwk | SymmetricJwk` (typed union).
Throws `CborError(UNEXPECTED_TYPE)` for incompatible curves (EC2 with a
non-P curve, OKP with a non-Ed/X curve), unknown JWK `alg` strings, and
malformed base64url input via the underlying `atob`.
## Implementation Notes
base64url encoding is implemented inline with `btoa`/`atob` rather than
pulling in `@suaveplan/crypto-utils` so this module stays free of crypto
deps. Standard base64 padding (`=`) is stripped on output and re-padded on
input — the RFC 4648 §5 spec allows omission of padding and major JWK
producers do strip it. RSA JWKs use lowercase param names (`dp`, `dq`, `qi`)
per RFC 7518 §6.3.2; COSE_Key uses the labels from RFC 8230 §4 (`-6`, `-7`,
`-8`). The bridge preserves the field-by-field correspondence.

View File

@@ -0,0 +1,55 @@
# `mac`
## Purpose
`COSE_Mac` (RFC 9052 §6.1) — multi-recipient MAC message in CBOR tag 97.
Each recipient gets a wrapped copy of the content-MAC key (CMK); the body
itself carries one MAC computed over the payload using the CMK. This module
ships encode/decode of the wire shape plus a kid-based recipient lookup.
## Features
- `encodeCoseMac` builds the 5-tuple `[bodyProtected, bodyUnprotected,
payload, tag, recipients]`. Per-recipient triples
`[recipientProtected, recipientUnprotected, encryptedKey]` carry the
wrapped-key bytes verbatim — wrap/unwrap is delegated to whatever scheme
the recipient header declares (direct, KEK, ECDH-ES, etc.).
- `decodeCoseMac` recovers the typed `CoseMac` view. Each recipient lands
in `msg.recipients[]` with its own `protectedRaw` byte string ready for
any per-recipient verification flow that needs it.
- `findRecipientByKid` does a constant-time-ish byte compare over the
recipient's `kid` (consulted in protected then unprotected headers).
- Empty recipient arrays are rejected — RFC 9052 §6.1 requires at least one.
## Usage
```ts
import { encodeCoseMac, decodeCoseMac, findRecipientByKid } from "@suaveplan/cbor/cose";
const wire = encodeCoseMac({
protectedHeader: { alg: "HS256" },
payload,
tag: macTag,
recipients: [
{ unprotectedHeader: { kid: kidA }, encryptedKey: wrapped },
{ unprotectedHeader: { kid: kidB }, encryptedKey: wrappedB },
],
});
const msg = decodeCoseMac(wire);
const mine = findRecipientByKid(msg, kidA);
```
## API
- `encodeCoseMac(input)` / `decodeCoseMac(bytes)`.
- `findRecipientByKid(msg, kid): CoseRecipient | undefined`.
- `COSE_MAC_TAG = 97`.
## Notes
The body's `tag` is computed over the canonical `MAC_structure` (RFC 9052
§6.3) using the CMK. This module does not implement the wrapping/unwrapping
of the CMK to each recipient — that lives one layer up, where the recipient-
specific algorithm (`A128KW`, `direct`, `ECDH-ES + A256KW`, …) is dispatched.
The helper here gives a typed shape for callers to plug those wraps into.

View File

@@ -0,0 +1,50 @@
# `mac0`
## Purpose
`COSE_Mac0` (RFC 9052 §6.2) — single-recipient MAC message in CBOR tag 17.
Used for symmetric-key authentication of CWT claim sets, vault attachment
manifests, and any payload where the producer and verifier share a key.
## Features
- `createCoseMac0` computes the HMAC tag in one call (HMAC-SHA-{256,384,512}
via `@suaveplan/crypto-hash`) and returns a fully-formed `COSE_Mac0` byte
sequence.
- `encodeCoseMac0` emits a pre-computed tag (useful when the MAC is generated
outside the call site, e.g. by an HSM).
- `decodeCoseMac0` parses both tagged and tagless wire forms.
- `verifyCoseMac0` re-computes the tag in constant time and rejects on any
mismatch.
- External AAD propagated through the MAC computation — required for any
CWT claim set carrying authenticated context.
## Usage
```ts
import { createCoseMac0, decodeCoseMac0, verifyCoseMac0 } from "@suaveplan/cbor/cose";
const wire = createCoseMac0({
protectedHeader: { alg: "HS256" },
payload: new TextEncoder().encode("hello"),
key,
});
const msg = decodeCoseMac0(wire);
const ok = verifyCoseMac0(msg, key);
```
## API
- `createCoseMac0(input): Uint8Array` — sign-and-encode in one call.
- `encodeCoseMac0(input): Uint8Array` — wire encode for a pre-computed tag.
- `decodeCoseMac0(bytes): CoseMac0` — parse tagged or tagless input.
- `verifyCoseMac0(msg, key, options?): boolean` — constant-time tag compare.
- `COSE_MAC0_TAG = 17`
## Notes
Constant-time comparison is implemented inline rather than reaching for
`@suaveplan/crypto-utils` to keep the dep graph minimal. The XOR-accumulator
returns `false` for any byte difference and for length mismatches — the
shape every Web-Crypto-style timing-safe equality function uses.

View File

@@ -0,0 +1,48 @@
# `sig-structure`
## Purpose
Builders for the canonical input to COSE signature, MAC, and AEAD
computations per RFC 9052 §4.4 / §6.3 / §5.3. Every signed/verified COSE
message commits to a deterministically-encoded array whose first element is
a context discriminator — `Signature1`, `Signature`, `CounterSignature`,
`MAC`, `MAC0`, `Encrypt`, `Encrypt0`, `Enc_Recipient`. The discriminator
prevents confusion attacks where a signature crafted in one context could
be replayed in another.
## Features
- `buildSign1Structure(protectedRaw, externalAad, payload)` — RFC 9052 §4.4
Sig_structure1.
- `buildSignStructure(context, bodyProtected, signProtected, externalAad, payload)`
— RFC 9052 §4.4 Sig_structure (Signature + CounterSignature).
- `buildMacStructure(context, protectedRaw, externalAad, payload)` —
RFC 9052 §6.3 MAC_structure (MAC + MAC0).
- `buildEncStructure(context, protectedRaw, externalAad)` — RFC 9052 §5.3
Enc_structure (Encrypt + Encrypt0 + Enc_Recipient).
- All output is deterministically CBOR-encoded so two callers with the same
inputs produce byte-identical bytes.
## Usage
```ts
import { buildSign1Structure } from "@suaveplan/cbor/cose";
import * as ed25519 from "@suaveplan/crypto-curve/ed25519";
const tbs = buildSign1Structure(protectedRaw, new Uint8Array(0), payload);
const sig = ed25519.sign(tbs, privateKey);
```
## API
- `buildSign1Structure(protectedRaw: Uint8Array, externalAad: Uint8Array, payload: Uint8Array): Uint8Array`
- `buildSignStructure(context: "Signature" | "CounterSignature", bodyProtected, signProtected, externalAad, payload): Uint8Array`
- `buildMacStructure(context: "MAC" | "MAC0", protectedRaw, externalAad, payload): Uint8Array`
- `buildEncStructure(context: "Encrypt" | "Encrypt0" | "Enc_Recipient", protectedRaw, externalAad): Uint8Array`
## Notes
The protected-bucket arguments are the SERIALISED byte string per §3 — not
the inner map. An empty protected bucket is a zero-length byte string; the
encoder helpers in `mac0.ts` and `sign1.ts` produce this form when the
caller's `protectedHeader` is empty.

View File

@@ -0,0 +1,58 @@
# `sign`
## Purpose
`COSE_Sign` (RFC 9052 §4.1) — multi-signer COSE message in CBOR tag 98.
Use when a payload needs to be co-signed by multiple parties (e.g. multi-
party attestation, escrowed signing). Each signer carries its own protected
header (typically with its own `alg`) and produces an independent signature
over the canonical `Sig_structure` for context `"Signature"`.
## Features
- `encodeCoseSign` builds the 4-tuple `[bodyProtected, bodyUnprotected,
payload, signatures]` with deterministic encoding and per-signer
`[sigProtected, sigUnprotected, signature]` triples.
- `decodeCoseSign` recovers the typed `CoseSign` view, including
`protectedRaw` byte strings on every level needed to recompute the
five-element `Sig_structure`.
- `verifyCoseSignAny` walks the signers in order against a 1:1 candidate-
key array. Returns the first index whose signature verifies, or `-1` on
no match. Caller decides whether one valid signer is enough or whether all
signers must verify (loop the array).
- Currently dispatches to ES256 + EdDSA via `@suaveplan/crypto-curve`;
unsupported algs return `false` (signer is skipped) rather than throwing
so the helper can scan a heterogeneous array without aborting.
## Usage
```ts
import { encodeCoseSign, decodeCoseSign, verifyCoseSignAny } from "@suaveplan/cbor/cose";
const wire = encodeCoseSign({
payload,
signatures: [
{ protectedHeader: { alg: "ES256" }, signature: sig1 },
{ protectedHeader: { alg: "EdDSA" }, signature: sig2 },
],
});
const msg = decodeCoseSign(wire);
const matchIndex = verifyCoseSignAny(msg, [keyA, keyB]);
```
## API
- `encodeCoseSign(input)` / `decodeCoseSign(bytes)`.
- `verifyCoseSignAny(msg, keys, options?): number` — returns matching index
or `-1`. Length mismatch between `keys` and `msg.signatures` throws
`UNEXPECTED_TYPE`.
- `COSE_SIGN_TAG = 98`.
## Notes
Sig_structure for multi-signer is 5-element:
`[context, body_protected, sign_protected, external_aad, payload]` — the
extra `sign_protected` discriminates the signer's own protected header from
the message-wide one. Both must be the SERIALISED byte strings, not the
inner maps.

View File

@@ -0,0 +1,60 @@
# `sign1`
## Purpose
`COSE_Sign1` (RFC 9052 §4.2) — single-signer COSE message in CBOR tag 18.
WebAuthn attestation, COSE-signed firmware, and CWT signature flows all
land here. This module ships the encode/decode/verify triple plus a typed
`CoseSign1` view.
## Features
- `encodeCoseSign1` produces both tagged (default) and tagless wire forms.
- `decodeCoseSign1` accepts either form transparently.
- `verifyCoseSign1` wires `@suaveplan/crypto-curve` (ES256, EdDSA) and uses
the canonical `Sig_structure1` from `sig-structure.ts`.
- Public-key argument is polymorphic: typed `Ec2Key` / `OkpKey` (the helper
builds the wire-level point form internally) OR a raw byte-string key for
callers that already have a primitive shape.
- `null` payload (detached signature) decodes correctly; verify rejects with
a clear "not yet supported" error.
## Usage
```ts
import { decodeCoseSign1, encodeCoseSign1, verifyCoseSign1 } from "@suaveplan/cbor/cose";
import { buildSign1Structure } from "@suaveplan/cbor/cose";
import * as ed25519 from "@suaveplan/crypto-curve/ed25519";
// Sign
const draft = decodeCoseSign1(
encodeCoseSign1({ protectedHeader: { alg: "EdDSA" }, payload, signature: new Uint8Array(64) }),
);
const tbs = buildSign1Structure(draft.protectedRaw, new Uint8Array(0), payload);
const sig = ed25519.sign(tbs, privateKey);
const wire = encodeCoseSign1({ protectedHeader: { alg: "EdDSA" }, payload, signature: sig });
// Verify
const msg = decodeCoseSign1(wire);
const ok = verifyCoseSign1(msg, publicKey);
```
## API
- `encodeCoseSign1(input: EncodeCoseSign1Input): Uint8Array`
- `decodeCoseSign1(bytes: Uint8Array): CoseSign1`
- `verifyCoseSign1(msg: CoseSign1, key: Ec2Key | OkpKey | Uint8Array, options?): boolean`
- `COSE_SIGN1_TAG = 18`
`CoseSign1` carries `protectedRaw` (the serialised inner-map bytes) so
callers can recompute Sig_structure1 verbatim — critical when verifying a
message they did not produce.
## Notes
ES256 uses noble-p256's compact `(r||s)` signature form and consumes a
SHA-256 digest of the TBS bytes (matches RFC 9053 §2.1). EdDSA verifies
the raw TBS bytes directly per RFC 8032 §5.1.7. ES384 and ES512 throw
"not implemented" today — `@suaveplan/crypto-curve` does not ship the P-384
or P-521 primitives yet, and the bridge surfaces the gap rather than
silently falling back.

View File

@@ -0,0 +1,53 @@
# `claims`
## Purpose
Encode and decode RFC 8392 CBOR Web Token claim sets. The seven IANA-
registered claims (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `cti`) are
typed; private claims keyed by IANA-numeric label survive the round-trip
unchanged.
## Features
- `encodeCwt(claims)` — deterministic claim-set encoding so token bytes are
byte-stable across runs.
- `decodeCwt(bytes)` — typed `CwtClaims` output with full validation at the
field boundaries.
- Time fields (`exp`, `nbf`, `iat`) accept `Date` on the input side; the
decoder produces `Date` from raw integers, bigints, or values wrapped in
CBOR tag 1 (epoch).
- `aud` accepts both `string` and `string[]` per RFC 8392 §3.1.
- Private claims threaded through `claims.private` keyed by integer label
number — opaque to the typed claims but preserved on round-trip.
## Usage
```ts
import { decodeCwt, encodeCwt } from "@suaveplan/cbor/cwt";
const wire = encodeCwt({
iss: "https://issuer.example",
exp: new Date("2030-01-01"),
cti: new Uint8Array([0x01, 0x02]),
private: { 100: "tenant-a" },
});
const claims = decodeCwt(wire);
```
## API
- `encodeCwt(claims: CwtClaims): Uint8Array`
- `decodeCwt(bytes: Uint8Array): CwtClaims`
Throws `CborError(UNEXPECTED_TYPE)` for malformed claim labels, wrong inner
types, non-string `iss`/`sub`, malformed `aud`, etc.
## Notes
`exp`/`nbf`/`iat` always serialise as integer epoch seconds rather than
`Tagged(1, …)` — both forms are valid on the wire per RFC 8392, but the
integer form is shorter and the deterministic profile prefers shortest-form.
Private claims must use integer labels per the CWT registry; non-integer
keys are silently dropped on encode (the `Object.entries` walk filters
them) — the typed `private` shape is keyed by `number`.

View File

@@ -0,0 +1,51 @@
# `verify`
## Purpose
End-to-end CWT verification: parse the COSE wrapper, verify the signature
or MAC, decode the claim set, and check the standard time-window plus
audience plus issuer claims. Two entry points cover the two RFC 8392
profiles — `verifyCwtSign1` for COSE_Sign1-wrapped tokens and
`verifyCwtMac0` for COSE_Mac0-wrapped tokens.
## Features
- Time-window enforcement honours `exp` (with optional clock-skew tolerance)
and `nbf` per RFC 8392 §7.
- Audience checks accept both `string` and `string[]` audience claims.
- Issuer check is exact-match.
- External AAD propagates through to the underlying COSE verify.
- Distinct error codes per failure (`CWT_SIGNATURE_INVALID`, `CWT_EXPIRED`,
`CWT_NOT_YET_VALID`, `CWT_AUDIENCE_MISMATCH`, `CWT_ISSUER_MISMATCH`) so
callers can branch on the root cause.
## Usage
```ts
import { verifyCwtSign1 } from "@suaveplan/cbor/cwt";
const result = verifyCwtSign1(tokenBytes, publicKey, {
audience: "my-service",
issuer: "trusted-issuer",
clockSkewMs: 30_000,
});
console.log(result.claims.sub);
```
## API
- `verifyCwtSign1(bytes, key, options?): CwtVerifyResult`
- `verifyCwtMac0(bytes, key, options?): CwtVerifyResult`
- `CwtVerifyOptions`: `externalAad`, `audience`, `issuer`, `nowMs`,
`clockSkewMs`.
- `CwtVerifyResult`: `{ valid: true; claims: CwtClaims }` — the function
throws rather than returning `valid: false`, so the type carries no
failure variant. Callers `try/catch` on the typed `CborErrorCode`.
## Notes
Detached payloads (a `null` payload field on the COSE wrapper) are
explicitly rejected — RFC 8392 §1 says "The CWT structure is a CBOR
representation of a JWT-style claims set", which presumes the claim bytes
are carried inline. A future enhancement could accept a caller-provided
detached payload buffer; for now the failure mode is loud.

View File

@@ -0,0 +1,91 @@
# `decoder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - half-float — Decodes 2 big-endian bytes representin…
- [half-float](./half-float.md) — Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript `number`. The decoder is always available — n…
- [well-formed](./well-formed.md) — `isWellFormed(bytes)` returns `true` iff `bytes` represent a single well-formed CBOR item per RFC 8949 §5.1. The functi…
- half-float — Decodes 2 big-endian bytes representin…
- [half-float](./half-float.md) — Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript `number`. The decoder is always available — n…
- [well-formed](./well-formed.md) — `isWellFormed(bytes)` returns `true` iff `bytes` represent a single well-formed CBOR item per RFC 8949 §5.1. The functi…
- half-float — Decodes 2 big-endian bytes representin…
- [half-float](./half-float.md) — Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript `number`. The decoder is always available — n…
- [well-formed](./well-formed.md) — `isWellFormed(bytes)` returns `true` iff `bytes` represent a single well-formed CBOR item per RFC 8949 §5.1. The functi…
- half-float — Decodes 2 big-endian bytes representin…
- [half-float](./half-float.md) — Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript `number`. The decoder is always available — n…
- [well-formed](./well-formed.md) — `isWellFormed(bytes)` returns `true` iff `bytes` represent a single well-formed CBOR item per RFC 8949 §5.1. The functi…
- [half-float](./half-float.md) — Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript `number`. The decoder is always available — n…
- [well-formed](./well-formed.md) — `isWellFormed(bytes)` returns `true` iff `bytes` represent a single well-formed CBOR item per RFC 8949 §5.1. The functi…
## Purpose
The CBOR decoder materialises an RFC 8949 byte sequence into a JavaScript
value. It defaults to strict mode — every §5.1 well-formedness rule is
enforced, including rejection of trailing bytes after the top-level item,
truncation, malformed UTF-8, unmatched break stops, and reserved minor-type
encodings. Lax mode applies §5.2 only and is provided for interop with
producers that emit looser CBOR.
## Features
- All seven major types decode end-to-end, including indefinite-length
byte/text strings and indefinite-length arrays/maps.
- Half-float (binary16) decode — always available; never gated by an option.
- BigInt fallback: `u64` values exceeding `Number.MAX_SAFE_INTEGER` decode to
`bigint`. Negative `u64` values likewise.
- Pluggable tag handling: callers pass a `Map<number, TagHandler>` and the
decoder substitutes handler output for the inner value. Unknown tags
return as `Tagged` per RFC 8949 §3.4.
- Bounded recursion + bounded item count — `maxDepth` (default 64) and
`maxItems` (default 1_000_000) caps prevent stack and memory blowups on
adversarial inputs.
- `decodeOne` returns the consumed byte count, enabling streaming and
sequence decoders to advance their cursors precisely.
## Usage: Basic Example
```ts
import { decode } from "@suaveplan/cbor/decoder";
const value = decode(bytes); // strict mode by default
```
## Usage: Advanced Example
```ts
import { decode } from "@suaveplan/cbor/decoder";
import { defaultTagHandlers } from "@suaveplan/cbor/tags";
// Decode with the IANA built-in tag handlers (Date, URL, RegExp, ...)
const tags = defaultTagHandlers().snapshot();
const value = decode(bytes, { mode: "strict" }, tags);
```
## API Reference
- `decode(bytes: Uint8Array, options?: DecoderOptions, tags?: ReadonlyMap<number, TagHandler>): DecodedValue`
- `decodeOne(bytes, options?, tags?): { value: DecodedValue; consumed: number }`
- `DecoderOptions`:
- `mode?: "strict" | "lax"` — default `"strict"`.
- `tagHandling?: "registered" | "raw"` — default `"registered"`. `"raw"`
yields `Tagged` instances even when a handler exists.
- `maxDepth?: number`, `maxItems?: number`, `allowTrailingBytes?: boolean`.
Throws `CborError` with codes `MALFORMED`, `UNEXPECTED_TYPE`,
`NESTED_TOO_DEEP`, `LENGTH_OVERFLOW`, `INDEFINITE_NOT_ALLOWED`.
## Implementation Notes
The decoder is single-pass and zero-copy where possible — `subarray` views
into the input buffer back returned `Uint8Array` byte strings. Indefinite
text/byte strings are reassembled chunk by chunk; nested indefinite chunks
are explicitly rejected per §3.2. UTF-8 decoding uses a fatal-mode
`TextDecoder` so invalid sequences raise `MALFORMED` rather than silently
substituting U+FFFD. The `decodeOne` exit point exposes the cursor position so
upstream sequence + streaming decoders can advance the input without
re-tokenising.

View File

@@ -0,0 +1,52 @@
# `half-float` (decoder side)
## Purpose
Decodes 2 big-endian bytes representing IEEE 754 binary16 to a JavaScript
`number`. The decoder is always available — never gated by an option —
because well-formed CBOR producers may emit `f9 xx xx` for any float value
that fits binary16 exactly, and the decoder MUST accept it per RFC 8949 §3.3.
## Features
- Full IEEE 754 binary16 coverage: sign, 5-bit exponent, 10-bit mantissa.
- ±0, ±Infinity, NaN, subnormals, normal values all decode correctly.
- Sub-normals decode via `mant * 2^-24`; normals via `(1 + mant/1024) * 2^(exp-15)`.
- NaN payloads are not preserved — JavaScript collapses NaN payloads, which
RFC 8949 §3.3 explicitly permits.
## Usage: Basic Example
```ts
import { decodeFloat16 } from "@suaveplan/cbor/decoder";
const value = decodeFloat16(0x3e, 0x00); // 1.5
```
## Usage: Advanced Example
```ts
import { decode, decodeFloat16 } from "@suaveplan/cbor/decoder";
// Manual half-float decode for an embedded protocol that prepends a length
function decodeFrame(frame: Uint8Array): number {
if (frame[0] !== 0xf9) throw new Error("expected binary16 head");
return decodeFloat16(frame[1] ?? 0, frame[2] ?? 0);
}
```
## API Reference
- `decodeFloat16(hi: number, lo: number): number` — accepts the two big-endian
bytes (high then low). Output is a JavaScript `number`. No errors thrown —
every 16-bit input represents a valid IEEE 754 binary16 value.
## Implementation Notes
The decoder uses bitfield extraction rather than a `DataView` because half-
float is not a primitive `Float16` type in any current JavaScript runtime,
and constructing a 16-bit view would require a dedicated buffer for each
call. Two integer arguments side-step that allocation cost on the hot path.
The function is intentionally branch-light — a single `if (exp === 0x1f)`
chooses between specials and finite values, and `exp === 0` selects the
subnormal scaling path.

View File

@@ -0,0 +1,57 @@
# `well-formed`
## Purpose
`isWellFormed(bytes)` returns `true` iff `bytes` represent a single
well-formed CBOR item per RFC 8949 §5.1. The function is the canonical
predicate that callers use BEFORE attempting a real decode — for example
gateways that need to reject malformed payloads with a specific error code
before threading them into business logic, or stream framers that need to
identify the boundary between two items without committing to materialising
either one.
## Features
- Full §5.1 coverage: trailing-byte rejection, truncation rejection, malformed
UTF-8 rejection, unmatched break-stop rejection, indefinite-length-chunk
type-mismatch rejection, length-overflow rejection.
- Pure boolean output — never throws. Every internal `CborError` is caught
and translated to `false`.
- Defers to the strict-mode decoder so the well-formedness criterion is
always exactly the criterion that the decoder enforces — no drift.
## Usage: Basic Example
```ts
import { isWellFormed } from "@suaveplan/cbor/decoder";
if (!isWellFormed(payload)) {
return new Response("malformed CBOR", { status: 400 });
}
```
## Usage: Advanced Example
```ts
import { decode, isWellFormed } from "@suaveplan/cbor/decoder";
function safeDecode(bytes: Uint8Array): unknown | null {
if (!isWellFormed(bytes)) return null;
return decode(bytes);
}
```
## API Reference
- `isWellFormed(bytes: Uint8Array): boolean` — never throws.
## Implementation Notes
The implementation is deliberately a thin wrapper over `decode(bytes, { mode:
"strict" })` rather than a hand-rolled scanner. This guarantees the predicate
agrees with the decoder on every malformed-input branch and removes a class
of bugs where the predicate accepts inputs the decoder rejects (or vice
versa). The cost is one full decode per call — for security-critical paths
where the decoded value is also needed, callers should call `decode`
directly and treat any thrown `CborError(MALFORMED)` as the not-well-formed
verdict.

View File

@@ -0,0 +1,65 @@
# `deterministic`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Convenience entry point for RFC 8949 §4.2 deterministic encoding. The
underlying `encode` accepts a `deterministic: true` option; this module
exposes a tiny `encodeDeterministic(value, options?)` wrapper that pins the
flag at `true` and forbids the caller from overriding it. Use this when the
encoded bytes must be byte-stable across runs — content-addressable storage,
hash digests, signature payloads, COSE structures, and CWT claim sets are
the canonical consumers.
## Features
- Sorted map keys — lexicographic byte order over the canonical CBOR-encoded
key bytes, NOT the JavaScript Map iteration order.
- Shortest-form integers — always-on regardless of mode.
- No indefinite-length items — caller cannot opt in.
- Half-float disabled in deterministic mode; floats serialise as the
shortest exact width among `{float32, float64}`.
- Rejects `undefined` with `CborError(NON_DETERMINISTIC)` — `undefined` has
no canonical CBOR representation, so it cannot be deterministically encoded.
## Usage: Basic Example
```ts
import { encodeDeterministic } from "@suaveplan/cbor/deterministic";
const m = new Map<string, number>([["b", 2], ["a", 1]]);
const bytes = encodeDeterministic(m);
// bytes are sorted by canonical key order
```
## Usage: Advanced Example
```ts
import { encodeDeterministic } from "@suaveplan/cbor/deterministic";
const a = encodeDeterministic({ z: 1, a: 2 });
const b = encodeDeterministic({ a: 2, z: 1 });
// a and b are byte-identical because key order is canonical, not insertion-order.
import { sha256 } from "@suaveplan/crypto-hash/sha256";
const digest = await sha256(a);
```
## API Reference
- `encodeDeterministic(value: unknown, options?: Omit<EncoderOptions, "deterministic">): Uint8Array`
The omitted `deterministic` field guarantees the flag stays pinned. Other
options (`maxDepth`, `maxItems`, `preferFloat16`) are honoured but
`preferFloat16` has no effect when `deterministic` is on.
## Implementation Notes
This module exists primarily for ergonomics — direct `encode(v, { deterministic: true })`
is identical at runtime. The wrapper guards against accidental
`deterministic: false` overrides and gives downstream readers a clear signal
about intent at the call site. Consumers who need to assert byte-stability
across runs should pair this with property tests over arbitrary
`JsonValue`-like inputs.

View File

@@ -0,0 +1,68 @@
# `diagnostic`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Emits RFC 8949 §8 diagnostic notation for any decoded CBOR value. Diagnostic
notation is the JSON-like text surface CBOR uses for log lines, error
messages, and human inspection. The format is lossless for booleans, integers,
floats, strings, byte strings (encoded as `h'...'` hex), arrays, maps, and
tagged values; objects are serialised with quoted string keys; `Date` values
are emitted as the canonical RFC 3339 tag-0 form.
This is a debug surface — never invoke from encode/decode hot paths. The
implementation makes a fresh string per call and walks the value recursively.
## Features
- Full primitive coverage: `null`, `undefined`, `boolean`, `number` (incl.
±0, ±Inf, NaN), `bigint`, `string`, `Uint8Array`, arrays, `Map`, objects.
- Tag rendering: `Tagged{tag, value}` → `tag(value-diagnostic)`.
- Byte strings rendered as `h'<hex>'` per RFC 8949 §8.1.
- String escapes for backslash, double quote, and ASCII control characters
(matches JSON-string escapes).
- `bytesToDiagnostic(bytes)` convenience: decode + format in one call. Tags
are decoded raw (no IANA-handler substitution) so the diagnostic output
reflects the exact wire-level structure.
## Usage: Basic Example
```ts
import { toDiagnostic } from "@suaveplan/cbor/diagnostic";
const repr = toDiagnostic({ name: "Alice", age: 30 });
// '{"name": "Alice", "age": 30}'
```
## Usage: Advanced Example
```ts
import { encode } from "@suaveplan/cbor/encoder";
import { Tagged } from "@suaveplan/cbor/tags";
import { bytesToDiagnostic } from "@suaveplan/cbor/diagnostic";
const wire = encode([new Tagged(0, "2026-05-10T12:00:00Z"), { id: 1 }]);
console.log(bytesToDiagnostic(wire));
// '[0("2026-05-10T12:00:00Z"), {"id": 1}]'
```
## API Reference
- `toDiagnostic(value: unknown): string` — recursively formats `value`.
- `bytesToDiagnostic(bytes: Uint8Array): string` — decode + format in one
pass; tag handling is `"raw"` so handlers do not substitute the inner
representation.
Neither function throws on a CBOR-decoded value — the input domain is
total. The byte-input variant inherits any decoder error.
## Implementation Notes
The output format follows the JSON-like surface from RFC 8949 §8 while
incorporating CBOR-specific extensions (the `h'...'` byte-string syntax, the
`tag(...)` tagged-item syntax). Numeric formatting preserves `-0` and the
three special float values; otherwise the standard `Number.toString()`
representation is used. The function is intentionally allocation-heavy:
debug paths can pay for clarity.

View File

@@ -0,0 +1,89 @@
# `encoder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - half-float — Encodes a JavaScript `number` as IEEE……
- [half-float](./half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- half-float — Encodes a JavaScript `number` as IEEE……
- [half-float](./half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- half-float — Encodes a JavaScript `number` as IEEE……
- [half-float](./half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- half-float — Encodes a JavaScript `number` as IEEE…
- [half-float](./half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- [half-float](./half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
## Purpose
The CBOR encoder serialises an arbitrary JavaScript value into RFC 8949 binary
form. It supports every CBOR major type (0–7) and applies preferred
serialisation rules — shortest-form integers always-on, shortest-exact float
width by default. It also exposes a deterministic-encoding mode (RFC 8949
§4.2) for callers that need byte-stable output across runs (e.g. signature
generation, content addressing).
## Features
- All seven major types: unsigned, negative, byte string, text string, array,
map, tag, and major-7 (simple values + floats).
- Shortest-form integers per RFC 8949 §4.2.1 — always-on, regardless of mode.
- Shortest-exact float selection among `{float16 (opt-in), float32, float64}`.
- Deterministic mode: sorted map keys (lexicographic over canonical encoded
key bytes), no indefinite-length items, no half-float, rejects `undefined`.
- BigInt support: ranges within `±u64` use major 0/1; outside that range, tag
2 (positive) or tag 3 (negative) bignum byte-string.
- `Tagged<T>` round-trip — encode emits tag-then-inner per §3.4.
- `Map<K,V>` and plain object literals both serialise to major 5 maps.
- Configurable `maxDepth` (default 64) and `maxItems` (default 1_000_000)
guard against pathological inputs.
## Usage: Basic Example
```ts
import { encode } from "@suaveplan/cbor/encoder";
const bytes = encode({ a: 1, b: [true, "two"] });
```
## Usage: Advanced Example
```ts
import { encode } from "@suaveplan/cbor/encoder";
import { Tagged } from "@suaveplan/cbor/tags";
// Deterministic encoding for signing
const claims = new Map<number, unknown>([
[1, "issuer"],
[4, new Tagged(1, 1700000000)], // exp claim with epoch tag
]);
const signedBytes = encode(claims, { deterministic: true });
```
## API Reference
- `encode(value: unknown, options?: EncoderOptions): Uint8Array`
- `EncoderOptions`:
- `deterministic?: boolean` — default `false`. Enforces RFC 8949 §4.2.
- `preferFloat16?: boolean` — default `false`. Try binary16 when exact.
Ignored when `deterministic` is `true`.
- `maxDepth?: number` — default 64. Throws `CBOR.NESTED_TOO_DEEP` on overflow.
- `maxItems?: number` — default 1_000_000. Throws `CBOR.LENGTH_OVERFLOW`.
Throws `CborError` with codes `UNEXPECTED_TYPE` (functions, symbols),
`NON_DETERMINISTIC` (e.g. `undefined` under deterministic), `NESTED_TOO_DEEP`,
and `LENGTH_OVERFLOW`.
## Implementation Notes
The encoder accumulates output as an array of `Uint8Array` chunks and
concatenates at the end. This avoids the O(n²) cost of repeatedly grow-and-
copying a single buffer. Map-key sorting in deterministic mode encodes each
key first (yielding the canonical bytes), then sorts the entry list by
lexicographic byte order before emitting headers and bodies. BigInt encoding
uses native `DataView.setBigUint64` for the in-range case and a hand-rolled
big-endian byte builder for tag 2/3 bignums to avoid pulling in any external
dependency.

View File

@@ -0,0 +1,58 @@
# `half-float` (encoder side)
## Purpose
Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25)
when the round-trip is exact, and otherwise indicates non-representability so
the caller can pick a wider format. Also exports `selectFloatWidth`, which
chooses between float32 and float64 based on exact-representability — the
encoder's deterministic path uses this to skip half-float without giving up
shortest-exact behaviour at the float32 / float64 boundary.
## Features
- Full IEEE 754 binary16 coverage: ±0, ±Infinity, NaN, normal values,
subnormals, smallest-normal / largest-normal boundaries.
- `tryEncodeFloat16(value)` returns the 2-byte big-endian sequence on exact
round-trip; returns `undefined` on overflow / underflow / inexact-precision.
- `selectFloatWidth(value)` picks float32 (4 bytes) when `getFloat32` round-
trips the value exactly, otherwise float64 (8 bytes). NaN is treated as
float32-exact since both float32 and float64 represent NaN.
## Usage: Basic Example
```ts
import { tryEncodeFloat16 } from "@suaveplan/cbor/encoder";
const bytes = tryEncodeFloat16(1.5); // Uint8Array(2) [0x3e, 0x00]
```
## Usage: Advanced Example
```ts
import { selectFloatWidth, tryEncodeFloat16 } from "@suaveplan/cbor/encoder";
function pickFloat(value: number): { tag: number; bytes: Uint8Array } {
const f16 = tryEncodeFloat16(value);
if (f16) return { tag: 0xf9, bytes: f16 };
const { width, bytes } = selectFloatWidth(value);
return { tag: width === 4 ? 0xfa : 0xfb, bytes };
}
```
## API Reference
- `tryEncodeFloat16(value: number): Uint8Array | undefined` — exact-only.
- `selectFloatWidth(value: number): { width: 4 | 8; bytes: Uint8Array }`.
- `type FloatWidth = 4 | 8`.
## Implementation Notes
The exact-only contract is critical for deterministic encoding: a lossy
half-float emission would produce different bytes for inputs that look
"close" but differ in the last bit of the mantissa. The implementation
verifies round-trip via reconstruction (`(1 + mant/1024) * 2^(exp-15)` for
normals, `mant * 2^-24` for subnormals) and rejects when the rebuild does
not equal the input under `===`. NaN is hard-coded to `7e00` (the canonical
quiet-NaN form) — RFC 8949 §3.3 explicitly permits losing NaN payload bits,
which JavaScript would do anyway since `Number` collapses payloads.

View File

@@ -0,0 +1,77 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable, registered error-code surface for `@suaveplan/cbor`. The
codes form the discriminator that every public entry point uses to signal a
specific failure class. Call sites do not parse messages; they branch on
`error.code`. This module also exports the `CborError` class extending the
`SuaveplanError` base, plus a `cborError(code, message, options)`
helper that centralises the construction pattern so codes are never typo'd at
call sites.
## Features
- 21 stable codes spanning RFC 8949 codec, RFC 8742 sequences, RFC 9052 COSE,
RFC 8392 CWT, and RFC 8610 CDDL.
- `defineCodes` registry — codes carry the `CBOR.` prefix and are typed as a
literal union for exhaustive `switch` checks.
- `CborError` extends `SuaveplanError<CborErrorCode>` so the suaveplan
observability surface (OTel span events, logger redaction, i18n hook)
flows through without per-package adapters.
- `cborError()` helper preserves an underlying `Error` cause when supplied
and silently drops non-Error causes (per `SuaveplanError` contract).
## Usage: Basic Example
```ts
import { cborError, CBOR_CODES, CborError } from "@suaveplan/cbor/errors";
if (bytes.length === 0) {
throw cborError(CBOR_CODES.MALFORMED, "empty CBOR input");
}
```
## Usage: Advanced Example
```ts
import { CBOR_CODES, type CborErrorCode, cborError } from "@suaveplan/cbor/errors";
function classify(err: unknown): "retryable" | "fatal" | "unknown" {
if (!(err instanceof CborError)) return "unknown";
const retry: ReadonlySet<CborErrorCode> = new Set([
CBOR_CODES.STREAM_INTERRUPTED,
]);
if (retry.has(err.code)) return "retryable";
return "fatal";
}
try {
/* decode */
} catch (e) {
const decision = classify(e);
// route to retry queue or dead-letter accordingly
}
```
## API Reference
- `CBOR_CODES: Readonly<Record<string, \`CBOR.${string}\`>>` — frozen registry.
- `type CborErrorCode` — literal union of every value in `CBOR_CODES`.
- `class CborError extends SuaveplanError<CborErrorCode>` — base error type.
- `cborError(code, message, { cause? }): CborError` — terse constructor.
Throws nothing; non-`Error` `cause` is dropped per `SuaveplanError` contract.
## Implementation Notes
The `defineCodes` call freezes the registry at module-load time, so the literal
type derives from the const tuple without ambient declaration drift. The seven
RFC 8949 codes (`MALFORMED`, `UNEXPECTED_TYPE`, `UNKNOWN_TAG_STRICT`,
`NESTED_TOO_DEEP`, `LENGTH_OVERFLOW`, `INDEFINITE_NOT_ALLOWED`,
`NON_DETERMINISTIC`) each map to a distinct §5.1 / §10 failure class — no
overlap, no fallthrough. Adding a new code requires adding it to the `as const`
tuple AND wiring it from at least one production path; the `error-codes.test.ts`
gate enforces this so dead codes never accumulate.

View File

@@ -0,0 +1,66 @@
# `sequences`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/cbor/sequences` implements RFC 8742 — the CBOR Sequence format,
which is just the concatenation of zero-or-more well-formed CBOR data items
with no enclosing array, tag, or framing byte. Sequences are the natural
encoding for log streams, event journals, and any wire format where items
arrive incrementally and the consumer does not know the count ahead of time.
## Features
- `encodeSequence(items, options?)` produces the byte-concatenated form of an
arbitrary item list. Empty input → empty output (zero bytes).
- `decodeSequence(bytes, options?, tags?)` walks the input from offset 0,
pulling one item at a time via `decodeOne` until the buffer is exhausted.
- `decodeSequenceStream(stream, options?, tags?)` — async generator over an
`AsyncIterable<Uint8Array>`. Yields each complete item as it becomes
available; buffers only what is needed for the next pending item.
- All three preserve the strict / lax mode and tag-handling semantics of the
underlying decoder — sequence framing is a pure outer concern.
## Usage: Basic Example
```ts
import { decodeSequence, encodeSequence } from "@suaveplan/cbor/sequences";
const wire = encodeSequence([{ id: 1, ok: true }, { id: 2, ok: false }]);
const items = decodeSequence(wire);
```
## Usage: Advanced Example
```ts
import { decodeSequenceStream } from "@suaveplan/cbor/sequences";
async function consumeLog(stream: AsyncIterable<Uint8Array>) {
for await (const event of decodeSequenceStream(stream)) {
handle(event);
}
}
```
## API Reference
- `encodeSequence(items: readonly unknown[], options?: EncoderOptions): Uint8Array`
- `decodeSequence(bytes: Uint8Array, options?: DecoderOptions, tags?: ReadonlyMap<number, TagHandler>): DecodedValue[]`
- `decodeSequenceStream(stream: AsyncIterable<Uint8Array>, options?, tags?): AsyncGenerator<DecodedValue>`
Throws `CborError(MALFORMED)` for sequence-internal incompleteness (e.g.
trailing bytes that don't form a complete item at end-of-stream).
## Implementation Notes
The streaming form coalesces partial chunks via a growing `Uint8Array`
buffer. After each new chunk is appended, the loop attempts as many full
`decodeOne` calls as the buffer allows; on the first failure (which signals
"need more bytes"), the loop breaks and waits for the next chunk. This
strategy is correct as long as `decodeOne` either succeeds and reports
exactly `consumed` bytes OR throws — both invariants are guaranteed by the
strict-mode decoder. The buffer is reallocated rather than `subarray`d after
each successful decode so backing-buffer ownership stays bound to the
generator and does not leak into yielded values.

View File

@@ -0,0 +1,98 @@
# `chunk-decoder`
## Purpose
Partial-item streaming decoder. While `PullDecoder` yields one whole CBOR
item at a time (waiting until each item is fully buffered), `ChunkDecoder`
emits structural events as bytes flow in — perfect for processing CBOR
items larger than memory. A multi-gigabyte indefinite-length byte string
can stream through with a fixed memory footprint, surfacing bytes-chunk
events as they arrive rather than buffering the whole body.
## Features
- Event stream: `value`, `string-chunk` / `string-end`, `bytes-chunk` /
`bytes-end`, `array-start` / `array-end`, `map-start` / `map-end`, `tag`,
`break`. Definite-length containers don't emit explicit ends — consumers
count entries via the `length` on the start event. Indefinite-length
containers do emit `array-end` / `map-end` when the break stop appears.
- Bounded memory: the internal buffer holds at most one item's head plus
the current chunk slice. Decoded values are NOT retained — consumers
materialise their own state from the event sequence.
- AbortSignal supported — `next()` rejects with `STREAM_INTERRUPTED`.
- Both `AsyncIterable<Uint8Array>` and `ReadableStream<Uint8Array>` sources;
the `Symbol.asyncIterator` async-iterator protocol is supported so
`for await (const event of decoder)` works directly.
## Usage: Basic Example
```ts
import { ChunkDecoder } from "@suaveplan/cbor/streaming";
const decoder = new ChunkDecoder(stream);
for await (const event of decoder) {
switch (event.kind) {
case "array-start":
pushContainer("array", event.length);
break;
case "value":
writeValue(event.value);
break;
case "bytes-chunk":
pipeToFile(event.chunk);
break;
case "bytes-end":
finalizeBlob();
break;
// ...
}
}
```
## Usage: Advanced Example
```ts
import { ChunkDecoder } from "@suaveplan/cbor/streaming";
// Stream a multi-GB indefinite byte string into a hash without buffering.
import { sha256 } from "@suaveplan/crypto-hash";
const decoder = new ChunkDecoder(httpStream);
const hashState = sha256.create();
for await (const event of decoder) {
if (event.kind === "bytes-chunk") hashState.update(event.chunk);
if (event.kind === "bytes-end") break;
}
const digest = hashState.finish();
```
## API Reference
- `class ChunkDecoder`
- `constructor(source, options?)` — `AsyncIterable<Uint8Array>` or
`ReadableStream<Uint8Array>` source; options accept `signal?: AbortSignal`.
- `next(): Promise<IteratorResult<ChunkDecoderEvent, void>>`.
- `[Symbol.asyncIterator]()`.
- `type ChunkDecoderEvent` — discriminated union of all 11 event kinds.
Throws `CborError` with codes `MALFORMED` (incomplete trailing bytes,
malformed UTF-8, mismatched chunk type, nested indefinite chunk, unexpected
break, indefinite-length on disallowed major type), `STREAM_INTERRUPTED`
(AbortSignal fired), `LENGTH_OVERFLOW` (length above MAX_SAFE_INTEGER).
## Implementation Notes
The decoder maintains a stack of pending container / string-context entries
plus a small head buffer. The `tryEmit` driver routes to a sub-decoder based
on the top of stack:
- `indef-string` / `indef-bytes` — drains chunk headers + bodies, emitting
`string-chunk` / `bytes-chunk` events incrementally.
- `definite-string` / `definite-bytes` — drains the body in chunks of
whatever's currently buffered, emitting one event per available slice.
- otherwise — reads a primitive head and dispatches by major type.
Empty definite-length containers don't push a stack entry; they call
`afterChild` directly to unwind the parent. UTF-8 decoding uses a fatal-mode
`TextDecoder` with `{ stream: true }` so multi-byte runes split across
chunks are reassembled correctly.

View File

@@ -0,0 +1,79 @@
# `pull-decoder`
## Purpose
`PullDecoder` is a pull-style streaming decoder over an
`AsyncIterable<Uint8Array>` or `ReadableStream<Uint8Array>`. Each call to
`next()` resolves once a complete CBOR item is available in the internal
buffer; until then, `PullDecoder` keeps reading chunks from the source.
Bounded memory — the internal buffer never holds more than one in-progress
item plus the current chunk's tail.
## Features
- Accepts both `AsyncIterable<Uint8Array>` (e.g. an async generator) and
`ReadableStream<Uint8Array>` (Web Streams) — `getReader()` is wrapped on
the fly when needed.
- Exposes `Symbol.asyncIterator` so consumers can `for await` directly.
- Honours an `AbortSignal` — `next()` rejects with
`CborError(STREAM_INTERRUPTED)` when the signal fires.
- Bounded memory: buffers only what is required for the next pending item;
reallocates after each successful decode so backing buffers do not leak.
- Reuses the production `decodeOne` cursor API so well-formedness is
identical to the synchronous decoder.
## Usage: Basic Example
```ts
import { PullDecoder } from "@suaveplan/cbor/streaming";
const decoder = new PullDecoder(stream);
for await (const item of decoder) {
handle(item);
}
```
## Usage: Advanced Example
```ts
import { PullDecoder } from "@suaveplan/cbor/streaming";
import { defaultTagHandlers } from "@suaveplan/cbor/tags";
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5_000);
const tags = defaultTagHandlers().snapshot();
const decoder = new PullDecoder(
fetchStream,
{ signal: ctrl.signal, mode: "strict" },
tags,
);
while (true) {
const r = await decoder.next();
if (r.done) break;
handle(r.value);
}
```
## API Reference
- `class PullDecoder`
- `constructor(source, options?, tags?)` — `source` is `AsyncIterable<Uint8Array>` or `ReadableStream<Uint8Array>`. Options extend `DecoderOptions` with `signal?: AbortSignal`.
- `next(): Promise<IteratorResult<DecodedValue, void>>` — resolves to the next item or `{ done: true }`.
- `[Symbol.asyncIterator]()` — async-iterator protocol; lets `for await` consume the decoder directly.
Throws `CborError` with codes `MALFORMED` (incomplete trailing bytes after
end-of-stream), `STREAM_INTERRUPTED` (AbortSignal fired), and any code the
underlying `decodeOne` would raise.
## Implementation Notes
The decoder maintains a single growing `Uint8Array` buffer. After each
successful `decodeOne`, the consumed prefix is dropped via a fresh allocation
rather than a `subarray` view so the backing `ArrayBuffer` is released as
soon as the previous chunk leaves scope. The "need more bytes" path is
expressed by catching the synchronous decoder's error and looping to fetch
the next chunk — this is correct because `decodeOne` only throws on either
genuine malformation OR truncation, and on truncation a subsequent attempt
with more bytes will succeed without regret.

View File

@@ -0,0 +1,64 @@
# `builtin`
## Purpose
Provides `defaultTagHandlers()`, a `TagRegistry` populated with handlers for
the most-used IANA-registered CBOR tags. Callers who want sensible
materialisation of standard tags (date-times, URIs, regular expressions,
positive/negative bignums, encoded-CBOR pointers, self-describe) can opt in
with a single call rather than registering each handler individually.
## Features
- Tag 0 (RFC 3339 date/time string) → `Date`. Throws `MALFORMED` on
unparseable input.
- Tag 1 (epoch seconds, integer or float) → `Date`.
- Tag 2 (positive bignum byte string) → `bigint`.
- Tag 3 (negative bignum byte string) → `bigint` of value `-1n - n`.
- Tag 21 / 22 / 23 (base64url / base64 / base16 expected encoding hints) →
identity. The hint indicates the *expected* encoding when serialising the
inner byte string to a text format; at decode time, the bytes themselves
are unchanged.
- Tag 24 (encoded CBOR data item) → re-decode the inner byte string.
- Tag 32 (URI string) → `URL`. Throws `MALFORMED` on invalid URIs.
- Tag 35 (regex string) → `RegExp`. Throws `MALFORMED` on invalid patterns.
- Tag 55799 (self-describe) → identity. The tag is a sentinel that says "the
following item is CBOR" — useful when CBOR is embedded in a multi-format
channel — and the inner item passes through unchanged.
## Usage: Basic Example
```ts
import { decode } from "@suaveplan/cbor/decoder";
import { defaultTagHandlers } from "@suaveplan/cbor/tags";
const tags = defaultTagHandlers().snapshot();
const value = decode(bytes, {}, tags);
```
## Usage: Advanced Example
```ts
import { decode } from "@suaveplan/cbor/decoder";
import { defaultTagHandlers } from "@suaveplan/cbor/tags";
// Layer a project-specific tag on top of the IANA defaults.
const reg = defaultTagHandlers();
reg.register(1700, (inner) => ({ kind: "tenant-id", inner }));
const value = decode(bytes, {}, reg.snapshot());
```
## API Reference
- `defaultTagHandlers(): TagRegistry` — returns a freshly constructed
registry populated with the tags listed above.
## Implementation Notes
Each handler validates the inner type before transforming. The tag-2/3
bignum handlers reconstruct the integer big-endian byte-by-byte rather than
delegating to a third-party big-int parser to keep the dependency surface
empty. The tag-32 handler uses `new URL(value)` so URI normalisation
(percent-encoding, lowercasing of host) follows whatever the runtime's URL
implementation does — callers needing strict round-trip preservation should
register a custom handler that returns the raw string instead.

View File

@@ -0,0 +1,65 @@
# `registry`
## Purpose
`TagRegistry` is the in-memory mutable container for CBOR tag handlers. The
decoder consults a snapshot of the registry on every encountered tag and
substitutes the handler's output for the inner value. The registry is the
extension point downstream packages use to add domain-specific tags
(WebAuthn AAGUIDs, vault-attachment manifests, custom timestamp formats)
without modifying `@suaveplan/cbor` itself.
## Features
- `register(tag, handler)` — adds a handler. Throws `CborError(UNEXPECTED_TYPE)`
on negative or non-integer tags AND on double-registration. Double-register
is treated as a programming error rather than silent override so two
libraries cannot quietly fight over the same tag value.
- `unregister(tag)` — removes a handler. Returns `true` if a handler was
removed, `false` otherwise.
- `lookup(tag)` — returns the handler or `undefined`.
- `snapshot()` — returns a detached `ReadonlyMap` for use with `decode()`.
Subsequent `register` / `unregister` calls do not mutate the snapshot.
- `size` — number of registered handlers.
## Usage: Basic Example
```ts
import { TagRegistry } from "@suaveplan/cbor/tags";
const reg = new TagRegistry();
reg.register(1001, (v) => `[domain]${String(v)}`);
```
## Usage: Advanced Example
```ts
import { decode } from "@suaveplan/cbor/decoder";
import { defaultTagHandlers, TagRegistry } from "@suaveplan/cbor/tags";
// Start from the IANA built-ins, layer one custom tag.
const reg = defaultTagHandlers();
reg.register(1234, (inner) => ({ kind: "custom", inner }));
const value = decode(bytes, {}, reg.snapshot());
```
## API Reference
- `class TagRegistry`
- `register(tag: number, handler: TagHandler): void` — throws on invalid
tag or duplicate.
- `unregister(tag: number): boolean`.
- `lookup(tag: number): TagHandler | undefined`.
- `snapshot(): ReadonlyMap<number, TagHandler>`.
- `get size: number`.
## Implementation Notes
The registry is a thin wrapper over `Map<number, TagHandler>`. The double-
register-throws contract is intentional — silent override would let a
late-loaded library shadow a legitimate built-in handler, and the resulting
behaviour change would be invisible at decode time. Callers who genuinely
want to replace a handler should call `unregister` first and then `register`
again. `snapshot()` returns a fresh `Map` (not a `ReadonlyMap` view of the
internal map) so external code cannot mutate the registry through a
captured snapshot.

View File

@@ -0,0 +1,59 @@
# `tagged`
## Purpose
Provides the `Tagged<T>` value object used by `@suaveplan/cbor` to materialise
RFC 8949 §3.4 tagged items. The decoder returns `Tagged` instances for any tag
without a registered handler (or always, when the caller requests raw-tag
decoding). Encoders accept `Tagged` instances on the input side to round-trip
arbitrary tags through the codec without modifying the registry. Holding the
tag alongside its value as a single object keeps tag semantics inspectable
even when callers do not pre-register a handler.
## Features
- Generic `Tagged<T>` so registered handlers can narrow the inner type.
- Constructor validates the tag — non-negative safe integer per RFC 8949 §3.4.
- `isTagged()` type guard for runtime branching.
- Plain class — no symbols, no proxies — so structured cloning, equality
testing, and JSON inspection all behave intuitively.
## Usage: Basic Example
```ts
import { Tagged } from "@suaveplan/cbor/tags";
const stamped = new Tagged(0, "2026-05-10T12:00:00Z");
```
## Usage: Advanced Example
```ts
import { decode } from "@suaveplan/cbor";
import { Tagged, isTagged } from "@suaveplan/cbor/tags";
const value = decode(bytes);
if (isTagged(value) && value.tag === 32) {
// RFC 8949 §3.4.5.3 — URI tag
const url = new URL(value.value as string);
// …
}
```
## API Reference
- `class Tagged<T = unknown>` — fields `tag: number` (non-negative safe
integer) and `value: T`. Constructor throws `RangeError` on a non-integer or
out-of-range tag.
- `isTagged(v: unknown): v is Tagged` — instanceof guard.
## Implementation Notes
`Tagged` is intentionally a plain class rather than a frozen object literal so
that subclassing remains an option for downstream consumers needing to attach
extra metadata (e.g. provenance for an attestation tag). The constructor
performs the validation eagerly because decoding a malformed tag is rare and
the synchronous throw beats deferring the check to encode time. `MAX_SAFE_INTEGER`
is the upper bound — RFC 8949 permits 64-bit unsigned tags but values above
`2^53 − 1` cannot round-trip through a JavaScript number; encoders that need
the full 64-bit range should accept `bigint` tags via a follow-up extension.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,139 @@
# sync-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CrdtSyncManager` is a delta-exchange coordinator that manages a heterogeneous
collection of CRDT instances and provides the primitives needed for peer-to-peer
state synchronization. It maintains a per-replica vector clock to track causal
history and exposes `exportDeltas` / `sync` operations that can be wired into
any transport layer (WebSocket, HTTP, MessageChannel, or in-process).
## Features
- Registry for any number of heterogeneous CRDT instances keyed by their id
- Local `VectorClock` that advances on `tick()` (call after batching mutations)
- `exportDeltas()` — snapshot all CRDT states with current clock for transport
- `sync(deltas)` — merge incoming deltas and advance local clock causally
- `syncWith(peer)` — convenience bidirectional in-process sync for testing
- `{ merged, skipped }` result counts for observability
- Injectable `nodeId` for deterministic replica identity in tests
## Usage: Basic Example
```ts
import { CrdtSyncManager } from "@suaveplan/crdt/core/sync-manager/sync-manager";
import { GCounter } from "@suaveplan/crdt/crdts/g-counter/g-counter";
const mgr = new CrdtSyncManager({ nodeId: "replica-1" });
const counter = new GCounter("replica-1");
mgr.register(counter);
counter.increment("replica-1");
mgr.tick(); // advance clock after local mutations
const deltas = mgr.exportDeltas();
// send `deltas` to a remote peer over any transport
```
## Usage: Advanced Example
```ts
import { CrdtSyncManager } from "@suaveplan/crdt/core/sync-manager/sync-manager";
import { GCounter } from "@suaveplan/crdt/crdts/g-counter/g-counter";
import { ORSet } from "@suaveplan/crdt/crdts/or-set/or-set";
const a = new CrdtSyncManager({ nodeId: "a" });
const b = new CrdtSyncManager({ nodeId: "b" });
const counterA = new GCounter("a", "shared-counter");
const counterB = new GCounter("b", "shared-counter");
const setA = new ORSet<string>("shared-set");
const setB = new ORSet<string>("shared-set");
a.register(counterA);
a.register(setA);
b.register(counterB);
b.register(setB);
counterA.increment("a", 3);
setA.add("item-1", "a");
a.tick();
counterB.increment("b", 2);
b.tick();
// Bidirectional sync
const { local, remote } = a.syncWith(b);
console.log(local); // { merged: 2, skipped: 0 }
console.log(remote); // { merged: 2, skipped: 0 }
// Both managers now have the same CRDT state
console.log(counterA.total()); // 5
console.log(counterB.total()); // 5
```
## API Reference
### `new CrdtSyncManager(options?)`
Creates a new manager. `options.nodeId` defaults to a random UUID.
### `register(crdt): CrdtRegistration`
Adds a CRDT to the registry using `crdt.id` as the key.
Returns a `CrdtRegistration` with `{ id, crdt }`.
### `unregister(id): void`
Removes a CRDT from the registry.
### `get<TValue, TState>(id): CRDT | undefined`
Returns the registered CRDT for the given id, or `undefined`.
### `ids(): string[]`
Returns all registered CRDT ids.
### `tick(): void`
Increments the local vector clock counter for this replica's node id.
Should be called after applying a batch of local mutations before exporting.
### `clockState(): Record<string, number>`
Returns the current local vector clock snapshot.
### `exportDeltas(): SyncDelta[]`
Produces one `SyncDelta` per registered CRDT, each containing:
- `crdtId` — CRDT identifier
- `state` — current CRDT state snapshot
- `vectorClock` — current local clock
### `sync(deltas): SyncResult`
Integrates incoming deltas from a remote peer. Advances local clock causally.
Returns `{ merged: number, skipped: number }`.
### `syncWith(peer): { local: SyncResult; remote: SyncResult }`
Bidirectional in-process sync: exports each manager's state, applies it to the
other, and returns both results.
## Implementation Notes
The vector clock is used for observability and causal bookkeeping rather than
delta filtering. Full-state exchange is deliberately chosen over delta-only
exchange to keep the implementation straightforward and avoid the need for
operation logs. For high-frequency or bandwidth-constrained deployments, a
delta-log approach should be layered on top of this manager.
The `sync` method merges the remote clock unconditionally after processing all
deltas, even for `skipped` entries. This ensures the local clock always reflects
the maximum causal horizon seen from the peer, preventing re-delivery of already
seen states on future syncs.

View File

@@ -0,0 +1,59 @@
# vector-clock
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a `VectorClock` implementation for tracking causality in distributed systems. Vector clocks are used by `MVRegister` to detect concurrent writes and are a foundational primitive for any CRDT that needs to track event ordering across multiple nodes.
## Features
- `increment(nodeId)` — increments the local counter for the given node
- `merge(remote)` — element-wise maximum merge (satisfies monotonicity)
- `compare(other)` — returns `"before" | "after" | "concurrent" | "equal"`
- `state()` — returns a snapshot `Record<string, number>`
- `clone()` — deep copies the clock
## Usage: Basic Example
```ts
import { VectorClock } from "@suaveplan/crdt";
const clock = new VectorClock();
clock.increment("node-1");
clock.increment("node-1");
console.log(clock.state()); // { "node-1": 2 }
```
## Usage: Advanced Example
```ts
import { VectorClock } from "@suaveplan/crdt";
const a = new VectorClock();
a.increment("node-1");
const b = new VectorClock();
b.increment("node-2");
console.log(a.compare(b.state())); // "concurrent"
a.merge(b.state());
console.log(a.compare(b.state())); // "after"
```
## API Reference
### `VectorClock`
- `constructor(initial?: VectorClockState)` — optional initial state
- `increment(nodeId: string): void`
- `merge(remote: VectorClockState): void` — element-wise max
- `compare(other: VectorClockState): "before" | "after" | "concurrent" | "equal"`
- `state(): VectorClockState` — snapshot
- `clone(): VectorClock`
## Implementation Notes
The comparison algorithm iterates all keys in the union of both clocks. Missing keys are treated as 0. Two clocks are concurrent when neither is strictly greater in all dimensions.

View File

@@ -0,0 +1,60 @@
# g-counter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `GCounter`, a grow-only counter CRDT. Each node in a distributed system maintains an independent counter; the global value is the sum of all node counters. Merge takes the element-wise maximum, ensuring convergence regardless of message ordering or duplicates.
## Features
- `increment(nodeId, delta?)` — adds `delta` (default 1) to the node's counter; rejects non-positive deltas
- `get(nodeId)` — returns the counter for a specific node
- `total()` — returns the sum across all nodes (equivalent to `value()`)
- `merge(state)` — element-wise maximum merge; idempotent and commutative
- `toJSON()` / `fromJSON()` — round-trip serialization
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { GCounter } from "@suaveplan/crdt";
const counter = new GCounter();
counter.increment("node-1", 5);
counter.increment("node-2", 3);
console.log(counter.total()); // 8
```
## Usage: Advanced Example
```ts
import { GCounter } from "@suaveplan/crdt";
const a = new GCounter("ctr-1");
a.increment("n1", 10);
const b = new GCounter("ctr-1");
b.increment("n2", 5);
a.merge(b.state());
console.log(a.total()); // 15
```
## API Reference
### `GCounter`
- `constructor(id?)` — optional stable ID
- `increment(nodeId, delta?)` — throws `OPERATION_INVALID` if delta ≤ 0
- `get(nodeId): number`
- `total(): number` — sum of all node counters
- `merge(state: GCounterState): void`
- `state(): GCounterState`
- `toJSON(): string` / `fromJSON(json: string): void`
- `clone(): this`
## Implementation Notes
The merge operation is O(k) where k is the number of distinct nodes. The `clone()` method uses the same `GCounter` constructor — the `as this` cast is needed because TypeScript cannot statically know the concrete subclass type.

View File

@@ -0,0 +1,54 @@
# lww-register
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `LWWRegister<T>`, a Last-Write-Wins Register CRDT. The register holds a single value; when two replicas have different values, the one with the higher timestamp wins during merge. Ties are broken by node ID lexicographic order, ensuring deterministic convergence.
## Features
- `set(value, timestamp?)` — sets the value with optional explicit timestamp (defaults to `Date.now()`)
- `get()` — returns the current value or `undefined`
- `merge(state)` — higher timestamp wins; tie-broken by nodeId lexicographic order
- `toJSON()` / `fromJSON()` — serialization round-trip
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { LWWRegister } from "@suaveplan/crdt";
const reg = new LWWRegister<string>("node-1");
reg.set("hello");
console.log(reg.get()); // "hello"
```
## Usage: Advanced Example
```ts
import { LWWRegister } from "@suaveplan/crdt";
const a = new LWWRegister<number>("n1");
a.set(42, 1000);
const b = new LWWRegister<number>("n2");
b.set(99, 2000);
a.merge(b.state());
console.log(a.get()); // 99 (higher timestamp)
```
## API Reference
### `LWWRegister<T>`
- `constructor(nodeId, id?)` — nodeId is required for tie-breaking
- `set(value, timestamp?)` — sets value with timestamp
- `get(): T | undefined` — current value
- `merge(state: LWWRegisterState<T>): void`
## Implementation Notes
Wall-clock timestamps from `Date.now()` can cause issues if clocks are not synchronized. For distributed deployments, use a monotonic or hybrid logical clock and pass the timestamp explicitly via `set(value, timestamp)`.

View File

@@ -0,0 +1,58 @@
# mv-register
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `MVRegister<T>`, a Multi-Value Register CRDT that preserves all concurrent writes rather than silently dropping them. When replicas diverge and merge, all concurrent values are returned by `get()`, allowing the application to detect and resolve conflicts. This makes MVRegister appropriate for collaborative editing scenarios where silent loss-of-data is unacceptable.
## Features
- `set(value)` — writes a new value, incrementing the node's vector clock
- `get()` — returns all current values (multiple on conflict, single on convergence)
- `conflict()` — returns `true` when more than one value is present
- `merge(state)` — keeps entries not dominated by any remote entry; drops dominated entries
- `toJSON()` / `fromJSON()` — serialization round-trip
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { MVRegister } from "@suaveplan/crdt";
const reg = new MVRegister<string>("node-1");
reg.set("hello");
console.log(reg.get()); // ["hello"]
console.log(reg.conflict()); // false
```
## Usage: Advanced Example
```ts
import { MVRegister } from "@suaveplan/crdt";
const a = new MVRegister<number>("n1");
a.set(42);
const b = new MVRegister<number>("n2");
b.set(99);
a.merge(b.state());
console.log(a.conflict()); // true
console.log(a.get()); // [42, 99] — both concurrent values preserved
```
## API Reference
### `MVRegister<T>`
- `constructor(nodeId, id?)` — nodeId for vector clock tracking
- `set(value: T): void` — advances vector clock, replaces local entries
- `get(): T[]` — returns all current values
- `conflict(): boolean`
- `merge(state: MVRegisterState<T>): void`
## Implementation Notes
The merge algorithm uses vector clock dominance: an entry is dropped if any remote entry has a strictly greater vector clock (happened after). Entries with concurrent clocks are both preserved. This guarantees that all concurrent writes survive the merge.

View File

@@ -0,0 +1,62 @@
# or-set
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `ORSet<T>`, an Observe-Remove Set CRDT. Unlike a 2P-Set, an OR-Set handles concurrent add and remove operations correctly: each addition is tagged with a unique identifier, and removal only tombstones the specific tags that were observed at the time of removal. Concurrent adds survive removes.
## Features
- `add(element, nodeId)` — tags the element with a unique per-node identifier
- `remove(element)` — tombstones all current (non-tombstoned) tags for the element
- `has(element)` — returns true if any live (non-tombstoned) tag exists
- `size()` — count of live unique elements
- `value()` — returns a `Set<T>` of all live elements
- `merge(state)` — merges remote entries; tombstone state wins on conflict
- `toJSON()` / `fromJSON()` — serialization round-trip
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { ORSet } from "@suaveplan/crdt";
const set = new ORSet<string>();
set.add("alice", "node-1");
set.add("bob", "node-1");
set.remove("alice");
console.log(set.has("alice")); // false
console.log([...set.value()]); // ["bob"]
```
## Usage: Advanced Example
```ts
// Concurrent add and remove converge correctly
const a = new ORSet<string>();
a.add("x", "n1");
const b = a.clone();
a.remove("x"); // n1 removes x
b.add("x", "n2"); // n2 re-adds x concurrently
a.merge(b.state());
console.log(a.has("x")); // true — concurrent add survives
```
## API Reference
### `ORSet<T>`
- `add(element: T, nodeId: string)` — adds with unique tag
- `remove(element: T)` — tombstones all current live tags
- `has(element: T): boolean`
- `size(): number`
- `value(): Set<T>`
- `merge(state: ORSetState<T>): void`
## Implementation Notes
Elements are compared with `===` (reference equality). For object elements, ensure consistent references or use string/number keys.

View File

@@ -0,0 +1,55 @@
# pn-counter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `PNCounter`, a positive-negative counter CRDT that supports both increment and decrement operations. Built from two G-Counters (one for increments, one for decrements), the net value is `sum(increments) - sum(decrements)`. All CRDT invariants (commutativity, associativity, idempotency) are preserved.
## Features
- `increment(nodeId, delta?)` — adds to the increment counter for `nodeId`
- `decrement(nodeId, delta?)` — adds to the decrement counter for `nodeId`
- `total()` — net value: sum of all increments minus sum of all decrements
- `merge(state)` — element-wise maximum on both increment and decrement counters
- `toJSON()` / `fromJSON()` — serialization round-trip
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { PNCounter } from "@suaveplan/crdt";
const counter = new PNCounter();
counter.increment("n1", 10);
counter.decrement("n1", 3);
console.log(counter.total()); // 7
```
## Usage: Advanced Example
```ts
import { PNCounter } from "@suaveplan/crdt";
const a = new PNCounter("likes");
a.increment("user-1", 5);
const b = new PNCounter("likes");
b.decrement("user-2", 2);
a.merge(b.state());
console.log(a.total()); // 3 (5 - 2)
```
## API Reference
### `PNCounter`
- `increment(nodeId, delta?)` — throws `OPERATION_INVALID` if delta ≤ 0
- `decrement(nodeId, delta?)` — throws `OPERATION_INVALID` if delta ≤ 0
- `total(): number` — net counter value
## Implementation Notes
The value can be negative if decrements exceed increments. This is intentional — PNCounter does not enforce a floor of 0.

View File

@@ -0,0 +1,136 @@
# rga
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the Replicated Growable Array (RGA) CRDT, a list-oriented conflict-free
replicated data type that supports concurrent insertions and deletions while
guaranteeing convergence to the same element order on all replicas. Each element
is tagged with a unique (sequence-number, node-id) identifier so that causally
ordered operations are reflected correctly and concurrent siblings are resolved
deterministically without coordination.
## Features
- Concurrent `insert` and `remove` operations with causal ordering
- Tombstone-based removal that preserves relative positions for later merges
- Tie-breaking via `(seq DESC, nodeId DESC)` for concurrent sibling inserts
- `idAt(index)` and `indexOf(id)` for positional queries on the visible array
- Full `merge`, `toJSON`/`fromJSON`, `clone`, and `state` CRDT interface
- `fromJSON` rebuilds from serialized state and advances internal seq counter
## Usage: Basic Example
```ts
import { RGA } from "@suaveplan/crdt/crdts/rga/rga";
const doc = new RGA<string>("replicaA");
const id0 = doc.insert("hello");
const id1 = doc.insert("world", id0);
console.log(doc.value()); // ["hello", "world"]
doc.remove(id0);
console.log(doc.value()); // ["world"]
```
## Usage: Advanced Example
```ts
import { RGA } from "@suaveplan/crdt/crdts/rga/rga";
// Two replicas insert concurrently after the same predecessor
const a = new RGA<string>("nodeA");
const b = new RGA<string>("nodeB");
const rootId = a.insert("root");
b.merge(a.state()); // b now knows about "root"
// Concurrent inserts after "root"
const aId = a.insert("from-A", rootId);
const bId = b.insert("from-B", rootId);
// Merge both ways
a.merge(b.state());
b.merge(a.state());
// Both replicas converge to the same order
console.log(a.value()); // deterministic total order: e.g. ["root", "from-B", "from-A"]
console.log(b.value()); // identical
```
## API Reference
### `new RGA<T>(nodeId?, id?)`
Creates a new RGA instance for the given replica node. `nodeId` defaults to
`"node"` and `id` defaults to a random UUID.
### `insert(value, afterId?): string`
Inserts `value` after the element identified by `afterId` (null or omitted = prepend).
Returns the new element's unique id.
### `remove(id): void`
Marks the element with `id` as a tombstone. The element is hidden from `value()`
but retained for future merge convergence.
### `value(): T[]`
Returns all non-tombstoned elements in document order.
### `idAt(index): string | undefined`
Returns the id of the element at the given visible index, or `undefined` if out
of range.
### `indexOf(id): number`
Returns the visible index of the element with `id`, or `-1` if tombstoned or
absent.
### `merge(remote: RGAState<T>): void`
Integrates a remote replica's state. New elements are inserted at their correct
positions; tombstones propagate (tombstone wins).
Throws `CrdtError(DESERIALIZE_FAILED)` if the remote type field does not match.
### `state(): RGAState<T>`
Returns a snapshot of the internal sequence including tombstones.
### `toJSON(): string`
Serializes state to a JSON string.
### `fromJSON(json): void`
Resets the instance and rebuilds from a JSON string.
Throws `CrdtError(DESERIALIZE_FAILED)` on malformed JSON or wrong type field.
### `clone(): this`
Returns an independent deep copy of the RGA instance.
## Implementation Notes
The predecessor-based insertion strategy is derived from the original RGA paper
(Roh et al., 2011). Each node records which element it was inserted after rather
than an absolute index, making the algorithm position-independent and robust to
concurrent inserts at the same predecessor.
Tie-breaking compares `(seq DESC, nodeId DESC)` — a higher sequence number wins,
and among equal sequences the lexicographically larger node-id wins. This matches
common RGA implementations and is consistent across all replicas given the same
inputs.
Tombstones are never removed (no garbage collection) to ensure replicas that
receive operations out of order can still converge. In production systems with
bounded document lifetime, a GC epoch mechanism should be layered on top.
The `predecessorFromElements` helper reconstructs predecessor links from the
flat ordered element list produced by `state()`. This keeps the wire format
simple (no extra predecessor field) at the cost of an O(n) scan per element
during merge — acceptable for document sizes encountered in practice.

View File

@@ -0,0 +1,56 @@
# two-p-set
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `TwoPSet<T>`, a Two-Phase Set CRDT. Elements can be added and removed exactly once; removed elements can never be re-added. The set value is the difference between the add-set and the remove-set. This guarantees eventual consistency at the cost of restricting re-addition.
## Features
- `add(element)` — adds to the A-set; throws `OPERATION_INVALID` if element was previously removed
- `remove(element)` — moves element to the R-set; no-op if not in A-set
- `has(element)` — true if in A-set but not R-set
- `value()` — returns a `Set<T>` of live elements
- `merge(state)` — union-merges both A-set and R-set
- `toJSON()` / `fromJSON()` — serialization round-trip
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { TwoPSet } from "@suaveplan/crdt";
const set = new TwoPSet<string>();
set.add("alice");
set.remove("alice");
console.log(set.has("alice")); // false
```
## Usage: Advanced Example
```ts
import { TwoPSet } from "@suaveplan/crdt";
const a = new TwoPSet<number>();
a.add(1);
const b = new TwoPSet<number>();
b.add(2);
a.merge(b.state());
console.log([...a.value()]); // [1, 2]
```
## API Reference
### `TwoPSet<T>`
- `add(element)` — throws `OPERATION_INVALID` if previously removed
- `remove(element)` — no-op if element not in A-set
- `has(element): boolean`
- `value(): Set<T>`
- `merge(state: TwoPSetState<T>): void`
## Implementation Notes
Elements are serialised to JSON for key comparison, enabling support for object elements with structural equality. The restriction that removed elements cannot be re-added is a fundamental invariant of 2P-Set that enables simple merge logic.

View File

@@ -0,0 +1,52 @@
# crdt-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error type and stable error-code registry for `@suaveplan/crdt`. CRDT operations should never throw in normal use — these errors guard against invalid states, deserialization failures, and programmer errors (invalid operations).
## Features
- `CRDT_CODES` registry via `@suaveplan/error`'s `defineCodes` — codes namespaced `CRDT.*`
- `CrdtError` extends `SuaveplanError` for cause-chain traversal
- Four codes: `MERGE_CONFLICT`, `INVALID_STATE`, `DESERIALIZE_FAILED`, `OPERATION_INVALID`
- `CrdtCode` union type for exhaustive switch statements
## Usage: Basic Example
```ts
import { CrdtError, CRDT_CODES } from "@suaveplan/crdt";
throw new CrdtError(CRDT_CODES.DESERIALIZE_FAILED, "Unknown CRDT type in JSON");
```
## Usage: Advanced Example
```ts
import { CrdtError, CRDT_CODES } from "@suaveplan/crdt";
function validateMerge(state: unknown): void {
if (typeof state !== "object" || state === null) {
throw new CrdtError(CRDT_CODES.INVALID_STATE, "State must be an object");
}
}
```
## API Reference
### `CRDT_CODES`
- `CRDT_CODES.MERGE_CONFLICT` — incompatible state types during merge
- `CRDT_CODES.INVALID_STATE` — deserialized or constructed state is structurally invalid
- `CRDT_CODES.DESERIALIZE_FAILED` — JSON deserialization failed
- `CRDT_CODES.OPERATION_INVALID` — attempt to remove from G-Set or similar invariant violation
### `CrdtError`
Extends `SuaveplanError<CrdtCode>`. Constructor: `(code, message, options?)`.
## Implementation Notes
Codes are prefixed with `"CRDT."` by `defineCodes`. Most CRDT methods are synchronous and do not throw in normal use — errors indicate programmer mistakes or data corruption.

View File

@@ -0,0 +1,48 @@
# crdt.types
## Purpose
Shared TypeScript type definitions for the `@suaveplan/crdt` package. Defines the `CRDT<TValue, TState>` base interface, all serializable state shapes, and construction options. All CRDT implementations depend on these types for consistent merge, serialization, and cloning contracts.
## Features
- `CRDT<TValue, TState>` — base interface with `value()`, `merge()`, `state()`, `toJSON()`, `fromJSON()`, `clone()`
- State types: `GCounterState`, `PNCounterState`, `ORSetState<T>`, `LWWRegisterState<T>`, `MVRegisterState<T>`, `TwoPSetState<T>`, `RGAState<T>`
- `VectorClockState` — alias for `Record<string, number>`
- `CrdtOptions` — base constructor options with optional `id` and `nodeId`
## Usage: Basic Example
```ts
import type { CRDT, GCounterState } from "@suaveplan/crdt";
function merge<T>(a: CRDT<T>, b: CRDT<T>): void {
a.merge(b.state());
}
```
## Usage: Advanced Example
```ts
import type { ORSetState } from "@suaveplan/crdt";
function serialize<T>(state: ORSetState<T>): string {
return JSON.stringify(state);
}
```
## API Reference
### `CRDT<TValue, TState>`
- `id: string` — stable identifier
- `value(): TValue` — current computed value
- `merge(remote: TState): void` — merges remote state
- `state(): TState` — serializable snapshot
- `toJSON(): string` — JSON serialisation
- `fromJSON(json: string): void` — deserialises into this instance
- `clone(): this` — deep copy
## Implementation Notes
All state types include a `type` discriminator string for runtime identification during deserialization. The `RGAElement<T>` uses `value: T | null` where `null` represents a tombstoned element.

View File

@@ -0,0 +1,173 @@
# @suaveplan/fsm - Typed finite state machine with guards, actions, history tracking, hierarchical states, parallel regions, deferred events, and OTel instrumentation
## Summary
Typed, generic finite state machine (FSM) for TypeScript. Ships a synchronous-by-default interpreter with guard predicates, ordered transition actions, context threading, entry/exit lifecycle hooks, history recording, and OTel instrumentation. Zero peer dependencies beyond `@suaveplan/error`, `@suaveplan/events`, and `@suaveplan/types`.
<!-- begin:auto-docs -->
## Documentation tree
- [interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fsm-interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fsm-error](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [context-helpers](./src/utils/context-helpers.md) — Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly int…
- [definition-validator](./src/utils/definition-validator.md) — Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtl…
<!-- end:auto-docs --> - [fsm-interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fsm-error](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [context-helpers](./src/utils/context-helpers.md) — Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly int…
- [definition-validator](./src/utils/definition-validator.md) — Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtl…
- [fsm-interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fsm-error](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [context-helpers](./src/utils/context-helpers.md) — Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly int…
- [definition-validator](./src/utils/definition-validator.md) — Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtl…
- [fsm-interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fsm-error](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [context-helpers](./src/utils/context-helpers.md) — Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly int…
- [definition-validator](./src/utils/definition-validator.md) — Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtl…
- [fsm-interpreter](./src/core/interpreter/fsm-interpreter.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fsm-error](./src/errors/fsm-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [context-helpers](./src/utils/context-helpers.md) — Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly int…
- [definition-validator](./src/utils/definition-validator.md) — Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtl…
## Install
```sh
bun add @suaveplan/fsm
```
## Quick Start
```ts
import { createMachine, assign } from '@suaveplan/fsm';
interface TrafficCtx { count: number }
const traffic = createMachine<TrafficCtx>({
id: 'traffic',
initial: 'red',
context: { count: 0 },
states: {
red: { on: { NEXT: { target: 'green' } } },
green: { on: { NEXT: { target: 'yellow' } } },
yellow: {
on: {
NEXT: {
target: 'red',
actions: [assign<TrafficCtx>(ctx => ({ count: ctx.count + 1 }))],
},
},
},
},
});
traffic.send('NEXT'); // → green
traffic.send('NEXT'); // → yellow
const state = traffic.send('NEXT'); // → red
console.log(state.context.count); // 1
console.log(state.history.length); // 3
```
## Modules
| Module | Path | Description |
|--------|------|-------------|
| Root | `@suaveplan/fsm` | `createMachine`, types, `assign`, `pure`, `validateDefinition` |
| Interpreter | `@suaveplan/fsm/interpreter` | `FSMInterpreter`, `createMachine` |
| Errors | `@suaveplan/fsm/errors` | `FSMError`, `FSM_CODES` |
## Core Concepts
### `createMachine(definition)`
Factory function returning an `FSMInterpreter` instance. The definition is validated eagerly — malformed definitions throw `FSMError(INVALID_DEFINITION)` before any events are processed.
```ts
interface FSMDefinition<TContext> {
id?: string; // used in OTel metric labels
initial: string; // must exist in states
context: TContext; // initial extended context
states: Record<string, StateDefinition<TContext>>;
}
```
### Guards
Guard functions on a transition prevent it from firing when they return `false`. If multiple transitions are declared for the same event (as an array), the first one whose guard returns `true` is taken.
```ts
states: {
locked: {
on: {
UNLOCK: [
{ target: 'open', guard: (ctx, evt) => evt.payload === ctx.pin },
{ target: 'locked', guard: () => true }, // fallthrough (stay locked)
],
},
},
}
```
### Actions
Transition actions are called with the accumulated context. Returning a new context replaces it; returning `void` leaves it unchanged.
```ts
import { assign, pure } from '@suaveplan/fsm';
const bump = assign<Ctx>(ctx => ({ ...ctx, count: ctx.count + 1 }));
const logEvt = pure<Ctx>((ctx, evt) => console.log(evt.type, ctx));
```
### Entry / Exit lifecycle
```ts
states: {
loading: {
entry: ctx => ({ ...ctx, loading: true }),
exit: ctx => ({ ...ctx, loading: false }),
on: { DONE: { target: 'idle' } },
},
}
```
### Final states
Entering a state with `type: 'final'` sets `state.done = true`.
### Subscriptions
```ts
const unsub = machine.onTransition(state => {
console.log('now in', state.value, 'done?', state.done);
});
// later:
unsub();
```
### `can()` and `matches()`
```ts
machine.can('NEXT'); // true if the event is declared in current state
machine.matches('green'); // true if currently in 'green'
```
### `reset()`
Resets to initial state, original context, and clears history.
## OTel Metrics
| Name | Type | Attributes |
|------|------|------------|
| `fsm.transition.total` | Counter | `id`, `from`, `to`, `event` |
| `fsm.transition.duration_ms` | Histogram | `id`, `from`, `to` |
## Error Codes
| Code | Meaning |
|------|---------|
| `FSM.INVALID_DEFINITION` | Structural error in definition (missing initial, unknown target, etc.) |
| `FSM.INVALID_TRANSITION` | No matching transition for event in current state |
| `FSM.INVALID_STATE` | Current state not found in definition |
| `FSM.GUARD_FAILED` | All guards for an event returned false |
| `FSM.ACTION_ERROR` | An action or lifecycle function threw |
## Module Docs
- [`src/core/interpreter/fsm-interpreter.md`](src/core/interpreter/fsm-interpreter.md)
- [`src/errors/fsm-error.md`](src/errors/fsm-error.md)
- [`src/utils/definition-validator.md`](src/utils/definition-validator.md)
- [`src/utils/context-helpers.md`](src/utils/context-helpers.md)

View File

@@ -0,0 +1,181 @@
# fsm-interpreter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `FSMInterpreter` class is the runtime engine for `@suaveplan/fsm`. It accepts a validated `FSMDefinition`, manages the current state value and extended context, executes entry/exit/transition actions, records a full immutable history of every transition taken, and emits `transition` events via an internal `EventBus` for reactive subscriptions.
`createMachine` is the primary factory function and validates the definition eagerly during construction, so malformed definitions are caught before any events are processed.
## Features
- Type-safe state transitions with generic `TContext` extended context
- Entry and exit lifecycle actions that may return an updated context
- Ordered transition action arrays with accumulated context threading
- Guard predicates on each transition candidate with first-match semantics
- Immutable `FSMState<TContext>` snapshots returned from every `send()` call
- Full `HistoryEntry[]` log with `from`, `event`, `to`, and `timestamp`
- `done: true` flag set automatically when entering a `type: 'final'` state
- `onTransition(listener)` subscription with typed state snapshots
- `reset()` returns to initial state and clears history
- OTel counter `fsm.transition.total` and histogram `fsm.transition.duration_ms`
- Graceful no-op when OTel is not available in the environment
## Usage: Basic Example
```ts
import { createMachine } from '@suaveplan/fsm';
const machine = createMachine({
id: 'traffic',
initial: 'red',
context: { count: 0 },
states: {
red: { on: { NEXT: { target: 'green' } } },
green: { on: { NEXT: { target: 'yellow' } } },
yellow: {
on: {
NEXT: {
target: 'red',
actions: [ctx => ({ count: ctx.count + 1 })],
},
},
},
},
});
machine.send('NEXT'); // red → green
machine.send('NEXT'); // green → yellow
const state = machine.send('NEXT'); // yellow → red
console.log(state.context.count); // 1
```
## Usage: Advanced Example
```ts
import { createMachine } from '@suaveplan/fsm';
import { assign } from '@suaveplan/fsm';
interface AuthCtx {
user: string | null;
attempts: number;
error: string | null;
}
const authMachine = createMachine<AuthCtx>({
id: 'auth',
initial: 'idle',
context: { user: null, attempts: 0, error: null },
states: {
idle: {
on: {
LOGIN: { target: 'authenticating' },
},
},
authenticating: {
entry: assign<AuthCtx>(ctx => ({ ...ctx, error: null })),
on: {
SUCCESS: {
target: 'authenticated',
actions: [assign<AuthCtx>((ctx) => ({ ...ctx, user: 'alice', attempts: 0 }))],
},
FAILURE: [
{
target: 'locked',
guard: (ctx) => ctx.attempts >= 2,
actions: [assign<AuthCtx>(ctx => ({ ...ctx, attempts: ctx.attempts + 1 }))],
},
{
target: 'idle',
actions: [assign<AuthCtx>(ctx => ({
...ctx,
attempts: ctx.attempts + 1,
error: 'Invalid credentials',
}))],
},
],
},
},
authenticated: { type: 'final' },
locked: { type: 'final' },
},
});
const unsub = authMachine.onTransition(state => {
console.log('Transitioned to:', state.value, 'done:', state.done);
});
authMachine.send('LOGIN');
authMachine.send({ type: 'FAILURE' });
authMachine.send('LOGIN');
authMachine.send('FAILURE');
authMachine.send('LOGIN');
const final = authMachine.send('FAILURE'); // → locked (guard: attempts >= 2)
console.log(final.done); // true
unsub(); // unsubscribe listener
```
## API Reference
### `class FSMInterpreter<TContext>`
#### Constructor
```ts
new FSMInterpreter(definition: FSMDefinition<TContext>)
```
Validates `definition` via `validateDefinition` on construction. Throws `FSMError(INVALID_DEFINITION)` for structural errors.
#### `get state(): FSMState<TContext>`
Returns an immutable snapshot with `value`, `context`, `history`, and `done`.
#### `get current(): StateValue`
Shorthand for `state.value`.
#### `send(event: FSMEvent | EventType): FSMState<TContext>`
Processes an event against the current state. Returns the new snapshot.
**Throws:**
- `FSMError(INVALID_TRANSITION)` — no matching (unguarded) transition exists
- `FSMError(ACTION_ERROR)` — an action or lifecycle function threw
- `FSMError(INVALID_STATE)` — current state not found in definition (should not happen post-construction)
#### `matches(state: StateValue): boolean`
Returns `true` if the current state value equals `state`.
#### `can(eventType: EventType): boolean`
Returns `true` if at least one transition for `eventType` is declared in the current state (regardless of guard outcome).
#### `reset(): void`
Resets to `definition.initial`, restores original `definition.context`, clears history.
#### `onTransition(listener): () => void`
Registers a synchronous listener called after each successful transition. Returns an unsubscribe function.
#### `toJSON(): FSMDefinition<TContext>`
Returns a serializable definition snapshot. Function-valued guards and actions are included as-is (not serializable to JSON text), but the shape is correct for in-memory round-tripping.
### `createMachine<TContext>(definition): FSMInterpreter<TContext>`
Factory function. Equivalent to `new FSMInterpreter(definition)`.
## Implementation Notes
OTel integration uses a dynamic `import('@opentelemetry/api')` wrapped in a try/catch. If OTel is absent from the environment, the import silently fails and metric recording is skipped. Meters and instruments are created per `send()` call via `getMeter().createCounter(...)` — this is safe because the OTel SDK caches instrument instances by name.
Context accumulation across multiple transition actions threads the result of each action into the next: if action A returns a new context, action B receives that new context rather than the original. This matches XState's "assign" pipeline semantics.
The internal `EventBus` is typed with `FSMEventMap<TContext>` to ensure the `transition` event payload is always a `FSMState<TContext>`.

View File

@@ -0,0 +1,106 @@
# fsm-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and typed error class for the `@suaveplan/fsm` package. Every error thrown by the FSM interpreter, definition validator, and context helpers extends `FSMError`, giving callers a single `instanceof` check and a machine-readable `code` property drawn from `FSM_CODES`.
The registry is built with `defineCodes` from `@suaveplan/error`, which guarantees that each code string is unique across the entire `@suaveplan/*` ecosystem and can be compared with `===` without string literals.
## Features
- Five stable error codes covering all failure scenarios: `INVALID_DEFINITION`, `INVALID_TRANSITION`, `INVALID_STATE`, `GUARD_FAILED`, `ACTION_ERROR`
- `FSMError` extends `SuaveplanError<FSMCode>` for narrow discriminated-union typing
- Supports optional `cause` and `context` metadata on every error instance
- Fully compatible with the `@suaveplan/error` wire-format serialization
## Usage: Basic Example
```ts
import { FSMError, FSM_CODES } from '@suaveplan/fsm/errors';
try {
machine.send('UNKNOWN_EVENT');
} catch (err) {
if (err instanceof FSMError) {
console.error(err.code, err.message);
// "FSM.INVALID_TRANSITION" "No valid transition for event in current state"
}
}
```
## Usage: Advanced Example
```ts
import { FSMError, FSM_CODES } from '@suaveplan/fsm/errors';
function handleFSMError(err: unknown): void {
if (!(err instanceof FSMError)) throw err;
switch (err.code) {
case FSM_CODES.INVALID_DEFINITION:
console.error('Machine definition is malformed:', err.message);
break;
case FSM_CODES.INVALID_TRANSITION:
console.warn('Event not handled in current state');
break;
case FSM_CODES.GUARD_FAILED:
console.info('Transition blocked by guard');
break;
case FSM_CODES.ACTION_ERROR:
console.error('Action threw during transition:', err.cause);
break;
default:
throw err;
}
}
```
## API Reference
### `FSM_CODES`
```ts
const FSM_CODES: {
INVALID_DEFINITION: 'FSM.INVALID_DEFINITION';
INVALID_TRANSITION: 'FSM.INVALID_TRANSITION';
INVALID_STATE: 'FSM.INVALID_STATE';
GUARD_FAILED: 'FSM.GUARD_FAILED';
ACTION_ERROR: 'FSM.ACTION_ERROR';
}
```
Stable string constants for each FSM error domain. Use these instead of raw string comparisons to benefit from TypeScript narrowing.
### `type FSMCode`
Union type of all values in `FSM_CODES`. Used as the generic parameter of `FSMError`.
### `class FSMError`
```ts
class FSMError extends SuaveplanError<FSMCode> {
constructor(
code: FSMCode,
message: string,
options?: { cause?: Error; context?: Record<string, unknown> }
)
}
```
**Parameters:**
- `code` — one of the `FSM_CODES` values; stored as `this.code`
- `message` — human-readable description of the failure
- `options.cause` — underlying error that triggered this one (chained)
- `options.context` — arbitrary key/value metadata (state name, event type, etc.)
**Throws:** Never (constructor only).
## Implementation Notes
The `defineCodes` helper from `@suaveplan/error` namespaces all codes under `"FSM."` to prevent collisions with codes from other packages sharing the same registry. The `as const` assertion on the descriptor object ensures the resulting type is a fully-discriminated union rather than `string`.
The `noUselessConstructor` biome lint suppression is intentional: the constructor is required to widen the `protected` base constructor to `public`, which is a documented pattern in this repository for all `SuaveplanError` subclasses.

View File

@@ -0,0 +1,144 @@
# fsm.types
## Purpose
Defines the complete TypeScript type vocabulary for `@suaveplan/fsm`. Every public interface, type alias, and generic constraint used by the interpreter, definition validator, context helpers, and package consumers lives here. Centralising types in one place prevents circular imports and makes the type contract auditable at a glance.
All types are pure TypeScript — no runtime code is emitted by this file.
## Features
- `StateValue` — opaque string alias for state node identifiers
- `EventType` — opaque string alias for event discriminants
- `FSMEvent<TPayload>` — typed event object sent to `send()` with optional payload
- `HistoryEntry` — immutable record of a single state transition with timestamp
- `FSMState<TContext>` — immutable snapshot of the machine's current state, context, history, and `done` flag
- `TransitionDefinition<TContext>` — single transition edge with optional guard and ordered action array
- `StateDefinition<TContext>` — full state node definition: entry/exit hooks, event-to-transition map, type, and nested states
- `FSMDefinition<TContext>` — top-level machine definition passed to `createMachine()`
## Usage: Basic Example
```ts
import type {
FSMDefinition,
FSMEvent,
FSMState,
TransitionDefinition,
} from '@suaveplan/fsm';
interface TrafficCtx { count: number }
const def: FSMDefinition<TrafficCtx> = {
id: 'traffic',
initial: 'red',
context: { count: 0 },
states: {
red: { on: { NEXT: { target: 'green' } } },
green: { on: { NEXT: { target: 'yellow' } } },
yellow: { on: { NEXT: { target: 'red' } } },
},
};
```
## Usage: Advanced Example
```ts
import type {
FSMDefinition,
StateDefinition,
TransitionDefinition,
FSMEvent,
FSMState,
HistoryEntry,
} from '@suaveplan/fsm';
interface AuthCtx {
user: string | null;
attempts: number;
}
// Typed transition with guard
const loginTransition: TransitionDefinition<AuthCtx> = {
target: 'authenticated',
guard: (ctx, event: FSMEvent<{ token: string }>) =>
typeof event.payload?.token === 'string',
actions: [
(ctx, event: FSMEvent<{ user: string }>) =>
({ ...ctx, user: event.payload?.user ?? null }),
],
};
// Typed state with entry/exit
const authState: StateDefinition<AuthCtx> = {
entry: (ctx) => ({ ...ctx, attempts: 0 }),
exit: (ctx) => ctx,
on: { LOGIN: loginTransition },
};
// Inspect the FSMState snapshot
function onTransition(state: FSMState<AuthCtx>): void {
const last: HistoryEntry | undefined = state.history[state.history.length - 1];
console.log(`Transitioned from ${last?.from} to ${state.value}`);
if (state.done) console.log('Machine is done');
}
```
## API Reference
### `type StateValue = string`
Opaque alias for state node names. Using a named alias rather than `string` makes function signatures self-documenting.
### `type EventType = string`
Opaque alias for event type discriminants.
### `interface FSMEvent<TPayload = unknown>`
- `type: EventType` — the event discriminant
- `payload?: TPayload` — optional structured payload carried with the event
### `interface HistoryEntry`
- `from: StateValue` — state before transition
- `event: FSMEvent` — the event that triggered the transition
- `to: StateValue` — state after transition
- `timestamp: number` — `Date.now()` at transition time
### `interface FSMState<TContext = unknown>`
- `value: StateValue` — current state name
- `context: TContext` — extended context at snapshot time
- `history: ReadonlyArray<HistoryEntry>` — full transition log (immutable copy)
- `done: boolean` — `true` when current state has `type: 'final'`
### `interface TransitionDefinition<TContext = unknown>`
- `target: StateValue` — destination state
- `guard?: (context, event) => boolean` — optional predicate; transition is skipped if it returns `false`
- `actions?: Array<(context, event) => TContext | undefined>` — ordered action functions; returning a value replaces context
### `interface StateDefinition<TContext = unknown>`
- `entry?: (context, event) => TContext | undefined` — called on state entry
- `exit?: (context, event) => TContext | undefined` — called before state exit
- `on?: Record<EventType, TransitionDefinition | TransitionDefinition[]>` — event-to-transition map
- `type?: 'final' | 'parallel'` — special state semantics
- `states?: Record<StateValue, StateDefinition>` — nested child states
- `initial?: StateValue` — required initial child (when `states` is defined)
### `interface FSMDefinition<TContext = unknown>`
- `id?: string` — optional identifier (used in OTel metric labels)
- `initial: StateValue` — initial state (must exist in `states`)
- `context: TContext` — initial extended context value
- `states: Record<StateValue, StateDefinition<TContext>>` — top-level state map
## Implementation Notes
All interfaces use `TContext = unknown` as the default generic parameter so that consumers who do not pass a context type get `unknown` rather than `any`, preserving type safety at call sites.
Action return types use `TContext | undefined` rather than `TContext | void` to satisfy Biome's `noConfusingVoidType` rule, which forbids `void` inside union types. In practice, returning `undefined` and returning nothing (implicit `void`) are semantically identical in the interpreter — both leave the context unchanged.
`ReadonlyArray<HistoryEntry>` on `FSMState.history` signals to consumers that they must not mutate the history array. The interpreter returns a fresh `slice()` on each `state` access to further enforce immutability.

View File

@@ -0,0 +1,107 @@
# context-helpers
## Purpose
Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly into `TransitionDefinition.actions` arrays. Without these helpers, every action must be written as an explicit arrow function returning a new context, which becomes verbose for simple partial-update or logging scenarios.
The two helpers mirror the `assign` and `pure` patterns popularised by XState, adapted to the `@suaveplan/fsm` action signature.
## Features
- `assign(patch)` — shallow-merges a static partial object into the context
- `assign(fn)` — delegates to a context-mapping function for complex derivations
- `pure(fn)` — wraps a side-effect function ensuring its return value never corrupts the context
- Both helpers are fully typed against the machine's `TContext` generic parameter
## Usage: Basic Example
```ts
import { assign, pure, createMachine } from '@suaveplan/fsm';
interface Ctx { count: number; lastEvent: string }
const machine = createMachine<Ctx>({
initial: 'idle',
context: { count: 0, lastEvent: '' },
states: {
idle: {
on: {
INCREMENT: {
target: 'idle',
actions: [
assign<Ctx>(ctx => ({ ...ctx, count: ctx.count + 1 })),
pure<Ctx>((ctx, evt) => { console.log('incremented', ctx, evt); }),
],
},
},
},
},
});
```
## Usage: Advanced Example
```ts
import { assign } from '@suaveplan/fsm';
interface WizardCtx {
step: number;
formData: Record<string, string>;
completed: boolean;
}
// Static patch (shallow merge)
const resetWizard = assign<WizardCtx>({ step: 1, completed: false });
// Functional update with derived logic
const advanceStep = assign<WizardCtx>(ctx => ({
...ctx,
step: ctx.step + 1,
completed: ctx.step + 1 >= 5,
}));
// Compose them in a transition
const machine = createMachine<WizardCtx>({
initial: 'wizard',
context: { step: 1, formData: {}, completed: false },
states: {
wizard: {
on: {
NEXT: { target: 'wizard', actions: [advanceStep] },
RESET: { target: 'wizard', actions: [resetWizard] },
},
},
},
});
```
## API Reference
### `assign<TContext>(updater): (context: TContext, event: FSMEvent) => TContext`
**Parameters:**
- `updater` — either a `Partial<TContext>` patch object or a function `(ctx: TContext) => TContext`
**Returns:** An action function compatible with `TransitionDefinition.actions`.
**Behaviour:**
- If `updater` is a plain object, it performs `{ ...context, ...updater }`.
- If `updater` is a function, it calls `updater(context)` and returns the result.
### `pure<TContext>(fn): (context: TContext, event: FSMEvent) => void`
**Parameters:**
- `fn` — a `(context: TContext, event: FSMEvent) => void` side-effect function
**Returns:** A wrapped action function whose return value is always `void`.
**Behaviour:** Calls `fn(context, event)` and discards any return value. This prevents accidental context mutations from side-effect functions that inadvertently return a value.
## Implementation Notes
`assign` accepts both object and function forms by checking `typeof updater === 'function'`. The cast `updater as (ctx: TContext) => TContext` is required because TypeScript cannot narrow a union of `Partial<TContext>` and `(ctx) => TContext` cleanly at the call site without the explicit cast.
`pure` exists as a safety wrapper rather than a direct alias because some developers write side-effect actions as `(ctx, evt) => doSomething()` where `doSomething` returns a non-void value. Without `pure`, that non-void value would be treated as a context replacement by the interpreter.

View File

@@ -0,0 +1,89 @@
# definition-validator
## Purpose
Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtle runtime bugs such as transitions leading to undefined states or machines that can never leave their initial state due to a misconfigured `initial` key.
The validator is called automatically by `FSMInterpreter`'s constructor and by `createMachine()`. It can also be called standalone to check definitions at authoring time, in build pipelines, or within test suites that assert that certain configurations are correct.
## Features
- Verifies the top-level `initial` state exists in `states`
- Recursively collects all transition `target` references (including nested state trees) and confirms every target is a known state name
- Ensures any state node that declares child `states` also provides an `initial` child key
- Ensures that declared `initial` children actually exist within their parent's `states` map
- Throws `FSMError` with code `FSM.INVALID_DEFINITION` and rich `context` metadata on any failure
## Usage: Basic Example
```ts
import { validateDefinition } from '@suaveplan/fsm';
validateDefinition({
initial: 'idle',
context: {},
states: {
idle: { on: { START: { target: 'active' } } },
active: { on: { STOP: { target: 'idle' } } },
},
});
// No error — definition is valid
```
## Usage: Advanced Example
```ts
import { validateDefinition } from '@suaveplan/fsm';
import { FSMError, FSM_CODES } from '@suaveplan/fsm/errors';
// Catch and inspect validation errors
try {
validateDefinition({
initial: 'ghost', // "ghost" is not in states
context: {},
states: { idle: {} },
});
} catch (err) {
if (err instanceof FSMError && err.code === FSM_CODES.INVALID_DEFINITION) {
// err.context?.initial === 'ghost'
console.error('Bad definition:', err.message);
}
}
// Hierarchical definition with nested initial
validateDefinition({
initial: 'form',
context: { step: 1 },
states: {
form: {
initial: 'step1',
states: {
step1: { on: { NEXT: { target: 'step2' } } },
step2: { on: { BACK: { target: 'step1' } } },
},
},
},
});
```
## API Reference
### `validateDefinition<TContext>(def: FSMDefinition<TContext>): void`
**Parameters:**
- `def` — the complete FSM definition to validate
**Returns:** `void` (pure side-effect: throws on invalid input)
**Throws:** `FSMError` with code `FSM.INVALID_DEFINITION` and a `context` object containing the offending state/target names.
**Generic parameter:**
- `TContext` — the machine's extended context type; used only for correct typing of the definition shape.
## Implementation Notes
State name collection is done with a recursive depth-first traversal of the `states` map. Both fully-qualified dotted names (e.g. `"form.step1"`) and short names (e.g. `"step1"`) are added to the lookup set so that authors can use either convention in their `target` strings.
The validator deliberately does **not** check guard function arity or action return types — those are runtime concerns enforced by TypeScript at compile time. The focus here is purely structural integrity of the state graph.

View File

@@ -0,0 +1,95 @@
# @suaveplan/idempotency - Idempotency key guard and dedup: store adapters (memory, database, redis), distributed locking, middleware, key generation, metrics, OTel instrumentation.
## Summary
Idempotency key guard and deduplication for async operations. The package provides a complete `pending → completed | failed` state machine, three store backends (memory, database, Redis), distributed locking across multiple processes, request middleware, key generation with payload fingerprinting, version history management, and OTel metrics instrumentation. It is designed to protect any operation that must not execute more than once per request — payment charges, email sends, record creation — regardless of retries or race conditions.
<!-- begin:auto-docs -->
## Documentation tree
- [distributed-guard](./src/distributed/distributed-guard.md) — Provides distributed idempotency protection via a pluggable `LockProvider` that serializes concurrent access across mul…
- [redis-lock-provider](./src/distributed/redis-lock-provider.md) — Provides a Redis-backed implementation of the `LockProvider` interface using atomic `SET NX` (set-if-not-exists) operat…
- [error-codes](./src/error-codes/error-codes.md) — Defines and registers all stable error codes and their HTTP/retry metadata for the `@suaveplan/idempotency` package. Ev…
- [error-codes](./src/error-codes/error-codes.md) — Defines and registers all stable error codes and their HTTP/retry metadata for the `@suaveplan/idempotency` package. Ev…
- [errors](./src/errors/idempotency-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [idempotency-error](./src/errors/idempotency-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [guard](./src/guard/idempotency-guard.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [idempotency-guard](./src/guard/idempotency-guard.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key](./src/key/key-generator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-generator](./src/key/key-generator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/metrics/idempotency-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [idempotency-metrics](./src/metrics/idempotency-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [middleware](./src/middleware/idempotency-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [idempotency-middleware](./src/middleware/idempotency-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [database-store](./src/store/database-store.md) — Provides a persistent `IdempotencyStoreAdapter<T>` backed by any relational database via the `IdempotencyDatabaseAdapte…
- [memory-store](./src/store/memory-store.md) — Provides an in-memory implementation of `IdempotencyStoreAdapter<T>` backed by a `Map`. It is the default store for uni…
- [redis-store](./src/store/redis-store.md) — Provides a Redis-backed `IdempotencyStoreAdapter<T>` with automatic key expiration using Redis native TTL. By accepting…
- [versioning](./src/versioning/key-versioning.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-versioning](./src/versioning/key-versioning.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [idempotency-error](./src/errors/idempotency-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [guard](./src/guard/idempotency-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotency-guard](./src/guard/idempotency-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [key](./src/key/key-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-generator](./src/key/key-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/metrics/idempotency-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotency-metrics](./src/metrics/idempotency-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/idempotency-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotency-middleware](./src/middleware/idempotency-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-store](./src/store/database-store.md) — Provides a persistent `IdempotencyStoreAdapter<T>` backed by any relational database via the `IdempotencyDatabaseAdapte…
- [memory-store](./src/store/memory-store.md) — Provides an in-memory implementation of `IdempotencyStoreAdapter<T>` backed by a `Map`. It is the default store for uni…
- [redis-store](./src/store/redis-store.md) — Provides a Redis-backed `IdempotencyStoreAdapter<T>` with automatic key expiration using Redis native TTL. By accepting…
- [versioning](./src/versioning/key-versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-versioning](./src/versioning/key-versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines and registers all stable error codes and their HTTP/retry metadata for the `@suaveplan/idempotency` package. Ev…
- [idempotency-error](./src/errors/idempotency-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [guard](./src/guard/idempotency-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotency-guard](./src/guard/idempotency-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [key](./src/key/key-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-generator](./src/key/key-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/metrics/idempotency-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotency-metrics](./src/metrics/idempotency-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/idempotency-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotency-middleware](./src/middleware/idempotency-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-store](./src/store/database-store.md) — Provides a persistent `IdempotencyStoreAdapter<T>` backed by any relational database via the `IdempotencyDatabaseAdapte…
- [memory-store](./src/store/memory-store.md) — Provides an in-memory implementation of `IdempotencyStoreAdapter<T>` backed by a `Map`. It is the default store for uni…
- [redis-store](./src/store/redis-store.md) — Provides a Redis-backed `IdempotencyStoreAdapter<T>` with automatic key expiration using Redis native TTL. By accepting…
- [versioning](./src/versioning/key-versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-versioning](./src/versioning/key-versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines and registers all stable error codes and their HTTP/retry metadata for the `@suaveplan/idempotency` package. Ev…
- [idempotency-error](./src/errors/idempotency-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [guard](./src/guard/idempotency-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotency-guard](./src/guard/idempotency-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [key](./src/key/key-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-generator](./src/key/key-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/metrics/idempotency-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotency-metrics](./src/metrics/idempotency-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/idempotency-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotency-middleware](./src/middleware/idempotency-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-store](./src/store/database-store.md) — Provides a persistent `IdempotencyStoreAdapter<T>` backed by any relational database via the `IdempotencyDatabaseAdapte…
- [memory-store](./src/store/memory-store.md) — Provides an in-memory implementation of `IdempotencyStoreAdapter<T>` backed by a `Map`. It is the default store for uni…
- [redis-store](./src/store/redis-store.md) — Provides a Redis-backed `IdempotencyStoreAdapter<T>` with automatic key expiration using Redis native TTL. By accepting…
- [versioning](./src/versioning/key-versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-versioning](./src/versioning/key-versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines and registers all stable error codes and their HTTP/retry metadata for the `@suaveplan/idempotency` package. Ev…
- [idempotency-error](./src/errors/idempotency-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [guard](./src/guard/idempotency-guard.md) — <!-- begin:auto-docs --> _(no submodules)_ - [idempotency-guard](./src/guard/idempotency-guard.md) — <!-- begin:auto-docs --> _(no submodules)_- [key](./src/key/key-generator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-generator](./src/key/key-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/metrics/idempotency-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [idempotency-metrics](./src/metrics/idempotency-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/idempotency-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ - [idempotency-middleware](./src/middleware/idempotency-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-store](./src/store/database-store.md) — Provides a persistent `IdempotencyStoreAdapter<T>` backed by any relational database via the `IdempotencyDatabaseAdapte…
- [memory-store](./src/store/memory-store.md) — Provides an in-memory implementation of `IdempotencyStoreAdapter<T>` backed by a `Map`. It is the default store for uni…
- [redis-store](./src/store/redis-store.md) — Provides a Redis-backed `IdempotencyStoreAdapter<T>` with automatic key expiration using Redis native TTL. By accepting…
- [versioning](./src/versioning/key-versioning.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-versioning](./src/versioning/key-versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Defines and registers all stable error codes and their HTTP/retry metadata for the `@suaveplan/idempotency` package. Ev…
## Install
```bash
bun add @suaveplan/idempotency
```
## Quick start
```ts
import { IdempotencyGuard } from "@suaveplan/idempotency/guard";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
const guard = new IdempotencyGuard({
store: new MemoryIdempotencyStore(),
ttlMs: 3_600_000, // 1 hour
});
// First call: executes the handler and caches the result
const result = await guard.execute("order-42", () => createOrder(payload));
// Subsequent calls with the same key: return the cached result immediately
const again = await guard.execute("order-42", () => createOrder(payload));
// result === again — handler was not called again
// With payload fingerprinting and AbortSignal
const controller = new AbortController();
const safe = await guard.execute(
"charge-99",
() => chargeCard(payload),
{ input: payload, signal: controller.signal }
);
```
## Modules
| Module | Description |
|--------|-------------|
| [src/guard/idempotency-guard.md](src/guard/idempotency-guard.md) | `IdempotencyGuard` — single-node idempotency guard with full state machine, injectable clock, AbortSignal support, and OTel instrumentation |
| [src/distributed/distributed-guard.md](src/distributed/distributed-guard.md) | `DistributedIdempotencyGuard` — multi-node guard backed by a pluggable `LockProvider`; `MemoryLockProvider` for tests; `RedisLockProvider` for production |
| [src/distributed/redis-lock-provider.md](src/distributed/redis-lock-provider.md) | `RedisLockProvider` — Redis-backed distributed lock provider with TTL and exponential back-off |
| [src/store/memory-store.md](src/store/memory-store.md) | `MemoryIdempotencyStore` — in-memory store for tests and single-process deployments; lazy TTL expiration |
| [src/store/database-store.md](src/store/database-store.md) | `DatabaseIdempotencyStore` — SQL-backed store via a generic `IdempotencyDatabaseAdapter`; auto-creates table; supports upsert |
| [src/store/redis-store.md](src/store/redis-store.md) | `RedisIdempotencyStore` — Redis-backed store with native key TTL and configurable namespace prefix |
| [src/key/key-generator.md](src/key/key-generator.md) | Key generation utilities: `generateIdempotencyKey`, `compositeKey`, `fingerprintPayload`, `fingerprintPayloadAsync` |
| [src/middleware/idempotency-middleware.md](src/middleware/idempotency-middleware.md) | `createIdempotencyMiddleware` — framework-agnostic middleware factory for Express, Fastify, Hono, or custom request types |
| [src/metrics/idempotency-metrics.md](src/metrics/idempotency-metrics.md) | `IdempotencyMetrics` — aggregate and rolling-window counters, OTel counter/histogram instruments, periodic reporter |
| [src/versioning/key-versioning.md](src/versioning/key-versioning.md) | `KeyVersioning` — multi-version record management with `reject`, `overwrite`, and `version` conflict strategies |
| [src/errors/idempotency-error.md](src/errors/idempotency-error.md) | Structured error hierarchy: `IdempotencyConflictError`, `IdempotencyMismatchError`, `LockAcquisitionError`, `VersionConflictError` |
| [src/error-codes.md](src/error-codes.md) | Stable `IDEMPOTENCY.*` error code registry |

View File

@@ -0,0 +1,91 @@
# `distributed-guard`
## Purpose
Provides distributed idempotency protection via a pluggable `LockProvider` that serializes concurrent access across multiple processes or nodes before consulting the idempotency store. The `DistributedIdempotencyGuard` guarantees exactly-once execution even when multiple service instances race to handle the same idempotency key simultaneously, preventing double-charge or double-write scenarios.
## Features
- `DistributedIdempotencyGuard` — wraps any store + lock provider for multi-node safety
- `MemoryLockProvider` — in-process lock map for unit tests and single-node deployments
- Exponential back-off with jitter on lock contention
- Automatic lock release in a `finally` block — never leaks locks on exception
- Configurable retry count, base delay, and lock TTL
- Fingerprint mismatch detection under the lock (same key, different payload)
- `AbortSignal` support propagated through lock acquisition retries
- Injectable `clock` for deterministic testing
## Usage: Basic Example
```ts
import { DistributedIdempotencyGuard, MemoryLockProvider } from "@suaveplan/idempotency/distributed";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
const guard = new DistributedIdempotencyGuard({
store: new MemoryIdempotencyStore(),
lockProvider: new MemoryLockProvider(),
ttlMs: 3_600_000,
lockTtlMs: 5_000,
});
const result = await guard.execute("payment-42", () => chargeCard());
```
## Usage: Advanced Example
```ts
import { DistributedIdempotencyGuard } from "@suaveplan/idempotency/distributed";
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
import { RedisLockProvider } from "@suaveplan/idempotency/distributed";
import { LockAcquisitionError } from "@suaveplan/idempotency";
const guard = new DistributedIdempotencyGuard({
store: new RedisIdempotencyStore({ client: redisAdapter }),
lockProvider: new RedisLockProvider({ client: redisAdapter, ownerId: "worker-1" }),
retryCount: 5,
retryBaseDelayMs: 50,
lockTtlMs: 10_000,
});
try {
await guard.execute("order-99", () => fulfillOrder(order), { input: order });
} catch (err) {
if (err instanceof LockAcquisitionError) {
console.error(`Lock failed after ${err.attempts} attempts`);
}
}
```
## API Reference
### `new DistributedIdempotencyGuard(config: DistributedGuardConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `store` | `IdempotencyStoreAdapter` | required | Record persistence |
| `lockProvider` | `LockProvider` | required | Distributed lock backend |
| `ttlMs` | `number` | `86_400_000` | Record TTL (24 h) |
| `lockTtlMs` | `number` | `10_000` | Lock TTL (10 s) |
| `retryCount` | `number` | `3` | Max lock acquisition retries |
| `retryBaseDelayMs` | `number` | `100` | Base back-off delay (ms) |
| `fingerprintFn` | `fn` | `fingerprintPayload` | Payload hasher |
| `clock` | `() => number` | `Date.now` | Monotonic clock |
### `execute<T>(key, fn, opts?): Promise<T>`
Acquires the distributed lock, checks the store, and executes `fn` if no cached result exists.
**Throws:** `LockAcquisitionError` | `IdempotencyMismatchError`
### `MemoryLockProvider`
In-memory lock map with TTL-based auto-expiration. Suitable for testing and single-process deployments.
**Methods:** `acquire(resource, ttlMs)`, `release(resource)`, `isLocked(resource)`, `clear()`
## Implementation Notes
- The lock key is prefixed `idempotency:lock:<key>` to avoid collisions with store keys.
- Exponential back-off delay formula: `baseDelay * 2^attempt + random(0, baseDelay)`.
- The lock is always released in `finally` — even when `fn` throws or the AbortSignal fires.
- `MemoryLockProvider.clock` is injectable to enable deterministic TTL expiration in tests.

View File

@@ -0,0 +1,79 @@
# `redis-lock-provider`
## Purpose
Provides a Redis-backed implementation of the `LockProvider` interface using atomic `SET NX` (set-if-not-exists) operations with TTL. The `RedisLockProvider` enables distributed mutual exclusion across multiple service instances when used inside `DistributedIdempotencyGuard`, preventing duplicate execution of the same idempotent operation in multi-node deployments.
## Features
- Atomic `SET NX` acquisition — no race conditions on concurrent acquisition attempts
- Owner-verified release — only the process that acquired the lock can release it
- Configurable key prefix for namespace isolation
- Auto-generated `ownerId` when not explicitly provided (timestamp + random suffix)
- Compatible with any `RedisClientAdapter` implementation
## Usage: Basic Example
```ts
import { RedisLockProvider } from "@suaveplan/idempotency/distributed";
import type { RedisClientAdapter } from "@suaveplan/idempotency/store";
declare const redisAdapter: RedisClientAdapter;
const lock = new RedisLockProvider({
client: redisAdapter,
ownerId: "worker-1",
});
const acquired = await lock.acquire("payment-42", 10_000);
if (acquired) {
try {
// critical section
} finally {
await lock.release("payment-42");
}
}
```
## Usage: Advanced Example
```ts
import { DistributedIdempotencyGuard } from "@suaveplan/idempotency/distributed";
import { RedisLockProvider } from "@suaveplan/idempotency/distributed";
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
const guard = new DistributedIdempotencyGuard({
store: new RedisIdempotencyStore({ client: redisAdapter }),
lockProvider: new RedisLockProvider({
client: redisAdapter,
prefix: "app:locks:",
ownerId: process.env.WORKER_ID,
}),
lockTtlMs: 15_000,
retryCount: 5,
});
```
## API Reference
### `new RedisLockProvider(config: RedisLockProviderConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `client` | `RedisClientAdapter` | required | Redis adapter |
| `prefix` | `string` | `"lock:"` | Key namespace prefix |
| `ownerId` | `string` | auto-generated | Unique owner identifier |
### `acquire(resource, ttlMs): Promise<boolean>`
Calls `client.setNx(prefix + resource, ownerId, ttlMs)`. Returns `true` if the lock was acquired.
### `release(resource): Promise<void>`
Reads the lock value; deletes the key only if it still contains `ownerId`. This check-then-delete sequence prevents releasing another owner's lock after TTL auto-expiry.
## Implementation Notes
- The owner-verified release (read + delete) is not atomic in this implementation. For production use with very short TTLs and high contention, consider a Lua script for atomic compare-and-delete. The current approach is safe for the typical use case where the lock TTL is significantly longer than the operation duration.
- The `ownerId` should be unique per process/pod; using a Kubernetes pod name or UUID is recommended in production.
- The key stored at `prefix + resource` is the `ownerId` string, not a JSON blob, keeping the Redis value minimal.

View File

@@ -0,0 +1,78 @@
# Idempotency Error Codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines and registers all stable error codes and their HTTP/retry metadata for the `@suaveplan/idempotency` package. Every error thrown by `@suaveplan/idempotency` carries one of these codes, enabling downstream consumers — HTTP adapters, retry wrappers, logging pipelines — to classify failures programmatically without string-matching against error messages.
## Features
- All codes grouped under the `IDEMPOTENCY` namespace (e.g. `IDEMPOTENCY.CONFLICT`)
- HTTP status, severity level, and retryability metadata for every code
- Exported as `IDEMPOTENCY_ERROR_METADATA` for use in HTTP problem-detail mappers
- `IdempotencyErrorCode` union type for exhaustive switch discrimination
- Registered once at module load via a side-effect import in `src/index.ts`
- Re-exported callable `registerIdempotencyErrorCodes` for hosts that clear and re-register in isolated tests
## Usage: Basic Example
```typescript
import { IDEMPOTENCY_ERROR_METADATA, type IdempotencyErrorCode } from "@suaveplan/idempotency/errors";
// Map a code to its HTTP status for a problem-detail response
function toHttpStatus(code: IdempotencyErrorCode): number {
return IDEMPOTENCY_ERROR_METADATA[code].httpStatus;
}
```
## Usage: Advanced Example
```typescript
import { IdempotencyError } from "@suaveplan/idempotency/errors";
import { IDEMPOTENCY_CODES } from "@suaveplan/idempotency/errors";
import { IDEMPOTENCY_ERROR_METADATA } from "@suaveplan/idempotency/errors";
// Express middleware error handler
app.use((err: unknown, req, res, next) => {
if (err instanceof IdempotencyError) {
const meta = IDEMPOTENCY_ERROR_METADATA[err.code as IdempotencyErrorCode];
res.status(meta.httpStatus).json({
type: `https://errors.suaveplan.com/idempotency/${err.code}`,
title: meta.description,
retryable: meta.retryable,
});
return;
}
next(err);
});
```
## API Reference
### `IDEMPOTENCY_ERROR_METADATA`
`Readonly<Record<IdempotencyErrorCode, IdempotencyCodeMetadata>>` — maps every error code to its metadata.
#### `IdempotencyCodeMetadata`
| Field | Type | Description |
|---|---|---|
| `httpStatus` | `number` | Suggested HTTP status code for this error |
| `severity` | `"info" \| "warning" \| "error"` | Log severity for this error |
| `retryable` | `boolean` | Whether the caller can retry the same request |
| `description` | `string` | Human-readable description for problem-detail bodies |
### `IdempotencyErrorCode`
Union type of all values in `IDEMPOTENCY_CODES`. Every concrete error class exposes a `.code` property of this type.
### `registerIdempotencyErrorCodes()`
Callable registration function. Called automatically on import via side-effect; exported for test isolation.
## Implementation Notes
The codes use `defineCodes` from `@suaveplan/error` for namespace collision protection. The metadata object is frozen at module load — callers that need different HTTP mappings should wrap `IDEMPOTENCY_ERROR_METADATA` rather than mutating it. The `retryable` flag reflects idempotent safety: `CONFLICT` and `MISMATCH` are not retryable because the same key exists with different state, while `STORE_ERROR` and `LOCK_ACQUISITION_FAILED` are retryable because they are transient.

View File

@@ -0,0 +1,97 @@
# `idempotency-error`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error hierarchy for `@suaveplan/idempotency`. All errors extend `SuaveplanError` from `@suaveplan/error`, giving downstream consumers (loggers, OTel adapters, HTTP problem-detail responders, wire serializers) a uniform error shape without extra glue code. Stable `IDEMPOTENCY.*` error codes allow machine-readable error classification across service boundaries.
## Features
- `IdempotencyError` abstract base extends `SuaveplanError<TCode>` narrowed to `IdempotencyCode`
- `IDEMPOTENCY_CODES` frozen constant for all stable code strings
- `IdempotencyConflictError` — in-flight duplicate request detected; operation pending
- `IdempotencyMismatchError` — same key reused with different request payload fingerprint
- `LockAcquisitionError` — distributed lock could not be acquired after N retries
- `VersionConflictError` — key versioning conflict when strategy is `'reject'`
- Typed context accessors (`key`, `attempts`, fingerprints) for ergonomic error handling
## Usage: Basic Example
```ts
import { IdempotencyConflictError, IdempotencyMismatchError } from "@suaveplan/idempotency/errors";
try {
await guard.execute(key, handler);
} catch (err) {
if (err instanceof IdempotencyConflictError) {
// safe to retry after TTL
console.warn("Already processing key:", err.key);
}
}
```
## Usage: Advanced Example
```ts
import { LockAcquisitionError, VersionConflictError } from "@suaveplan/idempotency/errors";
import { walkCauses } from "@suaveplan/error";
try {
await distributedGuard.execute(key, payload, handler);
} catch (err) {
if (err instanceof LockAcquisitionError) {
console.error(`Lock failed after ${err.attempts} attempts for key ${err.key}`);
}
// Traverse cause chain — works with SuaveplanError natively
for (const cause of walkCauses(err)) {
console.debug("cause:", cause.message);
}
}
```
## API Reference
### `IDEMPOTENCY_CODES`
Frozen record mapping short names to fully qualified `IDEMPOTENCY.*` codes:
| Key | Code |
|-----|------|
| `CONFLICT` | `IDEMPOTENCY.CONFLICT` |
| `MISMATCH` | `IDEMPOTENCY.MISMATCH` |
| `STORE_ERROR` | `IDEMPOTENCY.STORE_ERROR` |
| `LOCK_ACQUISITION_FAILED` | `IDEMPOTENCY.LOCK_ACQUISITION_FAILED` |
| `KEY_GENERATION_FAILED` | `IDEMPOTENCY.KEY_GENERATION_FAILED` |
| `VERSION_CONFLICT` | `IDEMPOTENCY.VERSION_CONFLICT` |
| `KEY_EXPIRED` | `IDEMPOTENCY.KEY_EXPIRED` |
| `KEY_INVALID` | `IDEMPOTENCY.KEY_INVALID` |
### `IdempotencyConflictError`
- **Constructor**: `(key: string, options?: SuaveplanErrorOptions)`
- **Properties**: `.key: string`
- **Code**: `"IDEMPOTENCY.CONFLICT"`
### `IdempotencyMismatchError`
- **Constructor**: `(key: string, options?: SuaveplanErrorOptions)`
- **Properties**: `.key: string`
- **Code**: `"IDEMPOTENCY.MISMATCH"`
### `LockAcquisitionError`
- **Constructor**: `(key: string, attempts: number, options?: SuaveplanErrorOptions)`
- **Properties**: `.key: string`, `.attempts: number`
- **Code**: `"IDEMPOTENCY.LOCK_ACQUISITION_FAILED"`
### `VersionConflictError`
- **Constructor**: `(key: string, existingFingerprint: string, newFingerprint: string, options?: SuaveplanErrorOptions)`
- **Properties**: `.key: string`
- **Code**: `"IDEMPOTENCY.VERSION_CONFLICT"`
## Implementation Notes
All concrete errors pass a literal `messageKey` string (`idempotency.errors.*`) that the i18n resolver in `@suaveplan/error` can translate if a resolver is configured; otherwise the key is used as the human-readable message. Context is attached via `options.context` and surfaced through the `SuaveplanError.context` property — never interpolated into the raw message string.

View File

@@ -0,0 +1,109 @@
# `idempotency-guard`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `IdempotencyGuard` class — the core single-node idempotency primitive for `@suaveplan/idempotency`. It wraps arbitrary async operations and ensures each is executed **at most once** per key, returning the cached result on repeat invocations. A full `pending → completed | failed` state machine enables safe concurrent retries and fingerprint-based mismatch detection.
## Features
- Full lifecycle state machine: `pending` → `completed | failed`
- Cache hit: returns the stored response without re-executing the function
- Conflict detection: throws `IdempotencyConflictError` for in-flight duplicates
- Fingerprint mismatch: throws `IdempotencyMismatchError` when payload changes
- Lock timeout: automatically re-executes if a `pending` record is stale
- `AbortSignal` support: cancels before the store lookup or before execution
- Injectable `clock` for deterministic testing
- Custom `fingerprintFn` for domain-specific payload hashing
- `has`, `revoke`, and `cleanup` management utilities
## Usage: Basic Example
```ts
import { IdempotencyGuard } from "@suaveplan/idempotency/guard";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
const guard = new IdempotencyGuard({
store: new MemoryIdempotencyStore(),
ttlMs: 3_600_000, // 1 hour
});
// First call: executes handler and caches result
const result1 = await guard.execute("order-42", () => createOrder());
// Second call with same key: returns cached result immediately
const result2 = await guard.execute("order-42", () => createOrder());
assert(result1 === result2);
```
## Usage: Advanced Example
```ts
import { IdempotencyGuard, IdempotencyConflictError } from "@suaveplan/idempotency/guard";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
const guard = new IdempotencyGuard({
store: new MemoryIdempotencyStore(),
ttlMs: 86_400_000,
lockTimeoutMs: 10_000,
clock: () => Date.now(),
});
// With fingerprinting + AbortSignal
const controller = new AbortController();
try {
const result = await guard.execute(
"order-99",
() => chargeCard(payload),
{ input: payload, signal: controller.signal }
);
} catch (err) {
if (err instanceof IdempotencyConflictError) {
// key is pending — safe to retry after lock TTL
}
}
```
## API Reference
### `new IdempotencyGuard(config: IdempotencyGuardConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `store` | `IdempotencyStoreAdapter` | required | Persistence adapter |
| `ttlMs` | `number` | `86_400_000` | Record TTL (24 h) |
| `lockTimeoutMs` | `number` | `30_000` | Pending record timeout (30 s) |
| `fingerprintFn` | `(input: unknown) => string` | `fingerprintPayload` | Payload hasher |
| `clock` | `() => number` | `Date.now` | Monotonic clock |
### `execute<T>(key, fn, opts?): Promise<T>`
Executes `fn` idempotently under `key`.
**Params:**
- `opts.input?: unknown` — payload used for fingerprint mismatch detection
- `opts.signal?: AbortSignal` — aborts before the store lookup or before `fn` is called
**Throws:** `IdempotencyConflictError` | `IdempotencyMismatchError` | `DOMException` (abort)
### `has(key): Promise<boolean>`
Returns `true` if a non-expired record exists for `key`.
### `revoke(key): Promise<boolean>`
Removes the record; returns `true` if it existed.
### `cleanup(): Promise<number>`
Removes all expired records. Returns the count removed.
## Implementation Notes
- The state machine always writes a `pending` record before executing `fn`, guaranteeing crash recovery on re-execution.
- `lockTimeoutMs` defines the window during which a `pending` record blocks concurrent callers. After expiry the guard assumes the in-flight operation crashed and re-executes.
- `failed` records are re-executable — the guard intentionally does not cache failures, enabling safe at-most-once semantics while allowing retry.
- The `clock` injection lets tests advance time deterministically without patching `Date.now` globally.

View File

@@ -0,0 +1,99 @@
# `key-generator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides idempotency key generation and payload fingerprinting utilities for `@suaveplan/idempotency`. Key generation uses `globalThis.crypto.randomUUID()` for random keys and a djb2 hash for deterministic composite keys. Fingerprinting enables mismatch detection: the same idempotency key reused with a different payload fingerprint triggers an `IdempotencyMismatchError`.
## Features
- `generateIdempotencyKey(config?)` — random UUID-based key with optional prefix/separator
- `compositeKey(...parts)` — deterministic djb2 hash of ordered string parts; bounded-length output
- `fingerprintPayload(payload)` — synchronous djb2 fingerprint with stable JSON serialization (key-order independent)
- `fingerprintPayloadAsync(payload)` — SHA-256 fingerprint via `globalThis.crypto.subtle` for cryptographic strength
- Recursive stable `stableStringify` ensuring `{ a: 1, b: 2 }` === `{ b: 2, a: 1 }`
- Approved use of `globalThis.crypto.randomUUID` and `globalThis.crypto.subtle` (see `tests/no-disallowed.test.ts`)
## Usage: Basic Example
```ts
import {
generateIdempotencyKey,
compositeKey,
fingerprintPayload,
} from "@suaveplan/idempotency/key";
// Random key with optional prefix
const key = generateIdempotencyKey({ prefix: "order" });
// e.g. "order:550e8400-e29b-41d4-a716-446655440000"
// Deterministic key from known identifiers
const k = compositeKey("user-42", "order-99");
// djb2 hex of "user-42:order-99"
// Payload fingerprint for mismatch detection
const fp = fingerprintPayload({ amount: 100, currency: "USD" });
```
## Usage: Advanced Example
```ts
import {
compositeKey,
fingerprintPayloadAsync,
generateIdempotencyKey,
} from "@suaveplan/idempotency/key";
// SHA-256 fingerprint for high-value operations
async function guardPayment(userId: string, body: PaymentBody) {
const key = compositeKey("payment", userId, body.orderId);
const fp = await fingerprintPayloadAsync(body);
return guard.execute(key, () => processPayment(body), body);
}
// Prefixed keys for multi-tenant systems
const tenantKey = generateIdempotencyKey({
prefix: `tenant:${tenantId}`,
separator: ":",
});
```
## API Reference
### `generateIdempotencyKey(config?: KeyGeneratorConfig): string`
Generates a random key using `globalThis.crypto.randomUUID()`. If `config.prefix` is set, it is prepended with the separator (default `':'`).
**Params:**
- `config.prefix?: string` — optional prefix string
- `config.separator?: string` — separator between parts (default `':'`)
**Returns:** `string` — a unique key
### `compositeKey(...parts: string[]): string`
Creates a deterministic key from one or more ordered string parts. Internally joins with `':'` and applies djb2 hashing to produce a fixed-length hex string.
**Throws:** `RangeError` when called with zero parts.
### `fingerprintPayload(payload: unknown): string`
Synchronous djb2 fingerprint of any JSON-serializable payload. Object key ordering is normalized before hashing.
**Returns:** `string` — 8-character hex fingerprint.
### `fingerprintPayloadAsync(payload: unknown): Promise<string>`
SHA-256 fingerprint using `globalThis.crypto.subtle.digest`. Requires an async context.
**Returns:** `Promise<string>` — 64-character lower-case hex string.
## Implementation Notes
- djb2 was chosen for synchronous fingerprinting due to its simplicity and zero-dependency nature. It is **not** collision-resistant for adversarial inputs; use `fingerprintPayloadAsync` where cryptographic strength matters.
- `stableStringify` sorts object keys recursively, making `{ b: 2, a: 1 }` and `{ a: 1, b: 2 }` produce identical digests. Arrays preserve order.
- `globalThis.crypto.randomUUID()` is available in Node 15+, Bun, Deno, and all modern browsers without any polyfill.
- `globalThis.crypto.subtle` is available in the same environments. It is **not** available in some older edge runtimes; fall back to `fingerprintPayload` if needed.

View File

@@ -0,0 +1,106 @@
# `idempotency-metrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `IdempotencyMetrics` class for collecting, aggregating, and reporting idempotency operation statistics. Supports both aggregate lifetime totals and rolling-window statistics for real-time dashboards. Optionally emits OpenTelemetry counters and histograms when a `Meter` is injected, enabling deep observability in OTel-instrumented deployments.
## Features
- Aggregate counters: `totalRequests`, `cacheHits`, `cacheMisses`, `conflicts`, `mismatches`
- Derived metrics: `hitRate`, `avgExecutionDurationMs`, `duplicateRequests`
- Rolling window snapshot via `getWindowedSnapshot()` — configurable window (default: 5 min)
- OTel counters: `idempotency.request.total`, `idempotency.store.operation.total`, `idempotency.lock.acquisition.total`
- OTel histogram: `idempotency.execution.duration_ms`
- Periodic reporting via configurable `MetricsReporter` callback
- `reset()` for isolated test scenarios
- Fluent `withMeter(meter)` for OTel wiring
## Usage: Basic Example
```ts
import { IdempotencyMetrics } from "@suaveplan/idempotency/metrics";
const metrics = new IdempotencyMetrics();
metrics.recordHit();
metrics.recordMiss();
metrics.recordDuration(45);
const snap = metrics.getSnapshot();
console.log(snap.hitRate); // 0.5
console.log(snap.avgExecutionDurationMs); // 45
```
## Usage: Advanced Example
```ts
import { IdempotencyMetrics } from "@suaveplan/idempotency/metrics";
import { metrics as otelMetrics } from "@opentelemetry/api";
const meter = otelMetrics.getMeter("my-service");
const idempotencyMetrics = new IdempotencyMetrics({
reporter: (snap) => logger.info("idempotency snapshot", snap),
reportIntervalMs: 30_000,
windowMs: 60_000, // 1-minute rolling window
}).withMeter(meter);
idempotencyMetrics.startReporting();
// ... application runs ...
idempotencyMetrics.stopReporting();
```
## API Reference
### `new IdempotencyMetrics(config?: IdempotencyMetricsConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `reporter` | `MetricsReporter` | — | Periodic snapshot callback |
| `reportIntervalMs` | `number` | `60_000` | Reporting interval (ms) |
| `windowMs` | `number` | `300_000` | Rolling window size (ms) |
### `withMeter(meter: Meter): this`
Wires up OTel instruments. Chainable.
### `recordHit() / recordMiss() / recordConflict() / recordMismatch(): void`
Increment the corresponding counter and push a timestamped event.
### `recordDuration(durationMs: number): void`
Record execution latency; updates `avgExecutionDurationMs` and the OTel histogram.
### `recordStoreOperation(operation): void`
Increments `idempotency.store.operation.total` with operation label.
### `recordLockAcquisition(result): void`
Increments `idempotency.lock.acquisition.total` with result label.
### `getSnapshot(): IdempotencyMetricsSnapshot`
Returns aggregate (lifetime) counters and derived metrics.
### `getWindowedSnapshot(): IdempotencyMetricsSnapshot`
Returns metrics for the configured rolling window only. Prunes stale events.
### `startReporting() / stopReporting(): void`
Start/stop the periodic reporter interval.
### `reset(): void`
Clears all counters and events.
## Implementation Notes
- Events are kept in a `TimestampedEvent[]` array; `pruneEvents` trims entries older than the rolling window on `getWindowedSnapshot`.
- OTel instruments are lazily created; if `withMeter` is never called, no OTel dependency is exercised at runtime.
- `stopReporting` is idempotent and safe to call multiple times.
- `reset` does not stop the reporter timer; call `stopReporting` first if needed.

View File

@@ -0,0 +1,90 @@
# `idempotency-middleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a framework-agnostic idempotency middleware factory that wraps request/response handlers with deduplication logic. `createIdempotencyMiddleware` reads an idempotency key from the request, checks the store, and returns the cached response or executes the handler and caches the result. Works with Express, Fastify, Hono, Fetch API, or any custom request/response abstraction.
## Features
- Generic `TReq` / `TRes` type parameters for full type safety
- Configurable key extraction from any request type
- Per-operation TTL overrides for granular cache expiration
- Fingerprint mismatch detection before returning cached responses
- `transformResponse` hook for adapter-specific response transformations
- Bypass behaviour: if no key is extracted, the handler runs without caching
- State machine write (`pending` → `completed | failed`) before executing handler
## Usage: Basic Example
```ts
import { createIdempotencyMiddleware } from "@suaveplan/idempotency/middleware";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
interface MyRequest { headers: Record<string, string>; body: unknown }
interface MyResponse { status: number; data: unknown }
const middleware = createIdempotencyMiddleware<MyRequest, MyResponse>({
store: new MemoryIdempotencyStore(),
extractKey: (req) => req.headers["idempotency-key"],
});
const result = await middleware(req, async (r) => ({ status: 200, data: await process(r.body) }));
console.log(result.cached); // true on second call with same key
```
## Usage: Advanced Example
```ts
const middleware = createIdempotencyMiddleware<MyRequest, MyResponse>({
store,
extractKey: (req) => req.headers["idempotency-key"],
extractInput: (req) => req.body,
extractOperation: (req) => req.headers["x-operation"],
ttlOverrides: {
"create-payment": 86_400_000, // 24h
"send-email": 3_600_000, // 1h
},
transformResponse: (res) => ({ ...res, fromCache: true }),
});
```
## API Reference
### `createIdempotencyMiddleware<TReq, TRes>(config): Handler`
Returns `async (request: TReq, handler: (req: TReq) => Promise<TRes>) => Promise<MiddlewareResult<TRes>>`.
**Config options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `store` | `IdempotencyStoreAdapter` | required | Record persistence |
| `extractKey` | `(req) => string \| undefined` | required | Key extraction |
| `extractInput` | `(req) => unknown` | — | Input for fingerprinting |
| `ttlMs` | `number` | `86_400_000` | Default TTL |
| `ttlOverrides` | `Record<string, number>` | — | Per-operation TTL |
| `extractOperation` | `(req) => string \| undefined` | — | Operation name for TTL lookup |
| `fingerprintFn` | `fn` | `fingerprintPayload` | Custom hasher |
| `transformResponse` | `(res: TRes) => TRes` | — | Response transformer |
### `MiddlewareResult<TRes>`
```ts
interface MiddlewareResult<TRes> {
response: TRes;
cached: boolean;
key: string;
}
```
**Throws:** `IdempotencyMismatchError` when the request fingerprint differs from the cached fingerprint.
## Implementation Notes
- The middleware always writes a `pending` record before calling the handler, ensuring crash recovery on retry.
- `failed` records do not block retries — if the handler throws, subsequent requests with the same key re-execute the handler.
- Bypassed requests (no key extracted) run the handler directly without touching the store.
- `transformResponse` is applied both on cache hit and on fresh execution, so the response shape is always consistent.

View File

@@ -0,0 +1,78 @@
# `database-store`
## Purpose
Provides a persistent `IdempotencyStoreAdapter<T>` backed by any relational database via the `IdempotencyDatabaseAdapter` interface. Stores idempotency records in a single flat table with JSON-serialised response columns. Auto-creates the table on first use when `autoCreate` is `true` (default), making zero-setup integration painless.
## Features
- Wraps any database that implements `IdempotencyDatabaseAdapter` (SQLite, PostgreSQL, MySQL, etc.)
- Auto-creates `idempotency_records` table on first use (configurable)
- Upsert semantics: `set` inserts or updates existing rows correctly
- JSON serialisation of arbitrary response payloads
- `cleanup(beforeTimestamp)` deletes expired rows and returns the removed count
- Generic `<T>` parameter for typed response payloads
- Injectable table name for multi-tenant or schema-isolated deployments
## Usage: Basic Example
```ts
import { DatabaseIdempotencyStore } from "@suaveplan/idempotency/store";
import type { IdempotencyDatabaseAdapter } from "@suaveplan/idempotency/store";
declare const myDbAdapter: IdempotencyDatabaseAdapter;
const store = new DatabaseIdempotencyStore({ adapter: myDbAdapter });
await store.set("order-123", {
key: "order-123", status: "completed", response: { id: 1 },
createdAt: Date.now(), updatedAt: Date.now(), expiresAt: Date.now() + 86400_000,
});
```
## Usage: Advanced Example
```ts
import { DatabaseIdempotencyStore } from "@suaveplan/idempotency/store";
const store = new DatabaseIdempotencyStore({
adapter: pgAdapter,
tableName: "payments.idempotency",
autoCreate: false, // table managed by migrations
});
// Periodic cleanup of expired records
setInterval(() => store.cleanup(Date.now()), 60_000);
```
## API Reference
### `new DatabaseIdempotencyStore<T>(config: DatabaseIdempotencyStoreConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `adapter` | `IdempotencyDatabaseAdapter` | required | Database client adapter |
| `tableName` | `string` | `"idempotency_records"` | Table name |
| `autoCreate` | `boolean` | `true` | Auto-create table if absent |
### `get(key): Promise<IdempotencyRecord<T> | undefined>`
Queries the table for the given key. Returns `undefined` if not found.
### `set(key, record): Promise<void>`
Upserts the record: updates if key exists, inserts otherwise.
### `delete(key): Promise<boolean>`
Deletes the row; returns `true` if a row was removed.
### `cleanup(beforeTimestamp): Promise<number>`
Deletes all rows with `expires_at < beforeTimestamp`. Returns the count removed.
## Implementation Notes
- `ensureTable` runs once per instance (guarded by `initialized` flag); it is idempotent and can be disabled by setting `autoCreate: false`.
- JSON serialisation uses `JSON.parse` / `JSON.stringify`; circular references or non-serializable values will throw at `set` time.
- The `protected` visibility on `adapter`, `tableName`, `rowToRecord`, and `ensureTable` allows subclasses to extend without full re-implementation.
- No connection pooling or transaction management is performed — that responsibility belongs to the injected adapter.

View File

@@ -0,0 +1,85 @@
# `memory-store`
## Purpose
Provides an in-memory implementation of `IdempotencyStoreAdapter<T>` backed by a `Map`. It is the default store for unit tests, local development, and single-process deployments that do not need persistence across restarts. Records are lazily expired on `get` and can be batch-pruned via `cleanup`.
## Features
- O(1) `get` / `set` / `delete` via a `Map` keyed by idempotency key
- Lazy TTL expiration: expired records are detected and removed on `get`
- `cleanup(beforeTimestamp)` for batch removal of expired records
- Injectable `clock` function for deterministic testing with `FakeClock`
- `size` getter and `clear()` for test assertions and store resets
- Generic `<T>` type parameter propagated through stored records
## Usage: Basic Example
```ts
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
import { IdempotencyGuard } from "@suaveplan/idempotency/guard";
const store = new MemoryIdempotencyStore();
const guard = new IdempotencyGuard({ store, ttlMs: 3600_000 });
const result = await guard.execute("key-1", () => fetchUser(1));
console.log(store.size); // 1
```
## Usage: Advanced Example
```ts
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
// Inject a fake clock for deterministic TTL testing
let now = 1_000_000;
const fakeClock = () => now;
const store = new MemoryIdempotencyStore(fakeClock);
await store.set("k", {
key: "k", status: "completed", response: 42,
createdAt: now, updatedAt: now, expiresAt: now + 1000,
});
// Advance clock past TTL
now += 5000;
const result = await store.get("k"); // undefined — expired
```
## API Reference
### `new MemoryIdempotencyStore<T>(clock?: Clock)`
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `clock` | `() => number` | `Date.now` | Monotonic clock for TTL checks |
### `get(key): Promise<IdempotencyRecord<T> | undefined>`
Returns the record if it exists and has not expired. Deletes and returns `undefined` for expired records.
### `set(key, record): Promise<void>`
Upserts a record unconditionally.
### `delete(key): Promise<boolean>`
Removes the record; returns `true` if it existed.
### `cleanup(beforeTimestamp): Promise<number>`
Iterates all stored records and removes those whose `expiresAt <= beforeTimestamp`. Returns the count removed.
### `size: number`
Current number of records in the map, including expired entries not yet pruned.
### `clear(): void`
Immediately removes all records.
## Implementation Notes
- The `clock` is injected at construction time rather than captured from `Date.now` directly, enabling fully deterministic unit tests without patching globals.
- Lazy expiration keeps `get` at O(1) while `cleanup` is O(n) — call it periodically in long-running processes.
- The `protected` visibility on `records` and `clock` allows subclasses to extend without re-implementing the core logic.

View File

@@ -0,0 +1,76 @@
# `redis-store`
## Purpose
Provides a Redis-backed `IdempotencyStoreAdapter<T>` with automatic key expiration using Redis native TTL. By accepting a generic `RedisClientAdapter` interface rather than a concrete library, this module integrates with `ioredis`, `redis`, Upstash Redis, or any compatible client without coupling to a specific driver.
## Features
- JSON-serialised records stored as Redis string values
- Automatic TTL set on every `set()` call based on `record.expiresAt`
- Fallback TTL (`defaultTtlSeconds`) for records whose expiry is in the past
- Configurable key prefix for namespace isolation (multi-tenant)
- `cleanup()` scans expired records server-side for manual purging
- Generic `<T>` type parameter for typed payloads
## Usage: Basic Example
```ts
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
import type { RedisClientAdapter } from "@suaveplan/idempotency/store";
declare const redisAdapter: RedisClientAdapter; // your ioredis wrapper
const store = new RedisIdempotencyStore({
client: redisAdapter,
prefix: "app:idempotency:",
});
```
## Usage: Advanced Example
```ts
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
const store = new RedisIdempotencyStore({
client: redisAdapter,
prefix: `tenant:${tenantId}:idempotency:`,
defaultTtlSeconds: 3600, // 1 hour fallback
});
// Manual cleanup (most records expire via Redis TTL automatically)
const removed = await store.cleanup(Date.now());
console.log(`Manually removed ${removed} stale records`);
```
## API Reference
### `new RedisIdempotencyStore<T>(config: RedisIdempotencyStoreConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `client` | `RedisClientAdapter` | required | Redis client adapter |
| `prefix` | `string` | `"idempotency:"` | Key namespace prefix |
| `defaultTtlSeconds` | `number` | `86400` | Fallback TTL when expiresAt is past |
### `get(key): Promise<IdempotencyRecord<T> | undefined>`
Fetches and JSON-parses the record. Returns `undefined` if not found (key expired or never set).
### `set(key, record): Promise<void>`
Serialises the record as JSON and stores it with a TTL computed from `record.expiresAt - Date.now()`.
### `delete(key): Promise<boolean>`
Calls `DEL` on the prefixed key; returns `true` if a key was removed.
### `cleanup(beforeTimestamp): Promise<number>`
Scans all keys under the prefix, parses each, and deletes those with `expiresAt < beforeTimestamp`. Returns the count removed.
## Implementation Notes
- Redis TTL handles the common expiration case automatically; `cleanup` is only needed for records that were not expired by Redis (e.g. records with very long TTLs where early manual deletion is desired).
- `scan` is used rather than `KEYS` to avoid blocking the Redis event loop in production.
- JSON serialisation assumes records are fully serialisable; `Date` objects should be stored as timestamps (numbers).

View File

@@ -0,0 +1,76 @@
# idempotency.types
## Purpose
Shared type definitions that act as the contract between the `IdempotencyGuard`, the pluggable store adapters, the distributed-lock providers, the middleware layer, and the metrics collector. Keeping these types in a single flat file means every subsystem imports the same shapes and no subsystem invents its own record format, which is critical for interoperability between in-memory, Redis, and database-backed stores.
## Features
- Zero runtime cost — all exports are pure TypeScript types
- `IdempotencyRecord<T>` captures the full lifecycle (`pending | completed | failed`) with request-body fingerprint for mismatch detection and optional version counter
- `IdempotencyStoreAdapter<T>` describes the minimal CRUD + `cleanup` surface every backend must implement
- `ConflictResolutionStrategy` encodes the three supported policies for same-key-different-payload collisions: `reject`, `overwrite`, `version`
- `IdempotencyGuardConfig` gathers store, TTL, conflict strategy, clock injection, and optional metrics into one strongly typed options bag
- `DistributedIdempotencyGuardConfig` and `LockProvider` extend the base shape with lease semantics for multi-node deployments
## Usage: Basic Example
```ts
import type {
IdempotencyRecord,
IdempotencyStatus,
} from "@suaveplan/idempotency/types/idempotency.types";
const record: IdempotencyRecord<{ orderId: string }> = {
key: "order-abc",
status: "completed",
response: { orderId: "ord_1" },
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86_400_000,
};
const status: IdempotencyStatus = record.status;
```
## Usage: Advanced Example
```ts
import type {
IdempotencyStoreAdapter,
IdempotencyGuardConfig,
} from "@suaveplan/idempotency/types/idempotency.types";
class MemoryStore<T> implements IdempotencyStoreAdapter<T> {
private map = new Map<string, import("@suaveplan/idempotency/types/idempotency.types").IdempotencyRecord<T>>();
async get(key: string) { return this.map.get(key); }
async set(key: string, r: any) { this.map.set(key, r); }
async delete(key: string) { return this.map.delete(key); }
async cleanup(before: number) {
let n = 0;
for (const [k, v] of this.map) if (v.expiresAt < before) { this.map.delete(k); n++; }
return n;
}
}
const config: IdempotencyGuardConfig = {
store: new MemoryStore(),
ttlMs: 3_600_000,
conflictStrategy: "reject",
clock: () => Date.now(),
};
```
## API Reference
- `Clock` — `() => number` alias for ms-since-epoch.
- `IdempotencyStatus` — `"pending" | "completed" | "failed"`.
- `IdempotencyRecord<T>` — full stored record including `fingerprint` and optional `version`.
- `ConflictResolutionStrategy` — `"reject" | "overwrite" | "version"`.
- `IdempotencyStoreAdapter<T>` — `get` / `set` / `delete` / `cleanup` interface.
- `IdempotencyGuardConfig` — top-level guard configuration.
- `DistributedIdempotencyGuardConfig` / `LockProvider` — distributed-mode extensions.
## Implementation Notes
The `fingerprint` field is an optional SHA-256 hex of the request body, computed at the middleware layer via `fingerprintPayload` and compared against stored records to detect same-key-different-payload conflicts early. `version` is only populated when `conflictStrategy: "version"` is active. The `cleanup(beforeTimestamp)` contract is intentionally batch-oriented so Redis and SQL adapters can implement it as a single ranged `ZREMRANGEBYSCORE` or `DELETE ... WHERE expires_at < ?` rather than looping per key.

View File

@@ -0,0 +1,106 @@
# `key-versioning`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides multi-version idempotency record management for scenarios where the same key may legitimately be reused with different input payloads over its lifetime. `KeyVersioning` wraps the idempotency store and enforces a configurable `ConflictResolutionStrategy`, maintaining an in-process version history for audit, rollback, and debugging.
## Features
- Three conflict strategies: `reject` (default), `overwrite`, `version`
- In-process version history with configurable `maxVersions` cap
- `resolve(key, input, fn?)` returns whether to execute and which version to write
- `addVersionEntry` records a snapshot into the history
- `getHistory`, `getLatestVersion`, `clearHistory` for inspection and cleanup
- Generic `<T>` parameter for typed version entries
- Compatible with any `IdempotencyStoreAdapter`
## Usage: Basic Example
```ts
import { KeyVersioning } from "@suaveplan/idempotency/versioning";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
const versioning = new KeyVersioning({
store: new MemoryIdempotencyStore(),
strategy: "version",
maxVersions: 5,
});
const { version, shouldExecute } = await versioning.resolve("order-42", payload);
if (shouldExecute) {
const result = await chargeCard(payload);
versioning.addVersionEntry("order-42", {
version,
fingerprint: fingerprintPayload(payload),
response: result,
status: "completed",
createdAt: Date.now(),
});
}
```
## Usage: Advanced Example
```ts
import { KeyVersioning, VersionConflictError } from "@suaveplan/idempotency/versioning";
const versioning = new KeyVersioning({ store, strategy: "reject" });
try {
const { shouldExecute } = await versioning.resolve("key", differentPayload);
} catch (err) {
if (err instanceof VersionConflictError) {
// Log fingerprint conflict
console.warn("Version conflict for key:", err.key);
}
}
// Inspect history
const history = versioning.getHistory("order-42");
console.log("Versions:", history.map((e) => e.version));
```
## API Reference
### `new KeyVersioning<T>(config: KeyVersioningConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `store` | `IdempotencyStoreAdapter` | required | Record persistence |
| `strategy` | `ConflictResolutionStrategy` | `"reject"` | Conflict strategy |
| `maxVersions` | `number` | `10` | Max history entries per key |
### `resolve(key, input, fingerprintFn?): Promise<{ version: number; shouldExecute: boolean }>`
Checks the store and applies the conflict strategy. Returns:
- `shouldExecute: true` — caller should invoke `fn`
- `shouldExecute: false` — cached result can be returned
**Throws:** `VersionConflictError` when strategy is `'reject'` and a conflict is detected.
### `addVersionEntry(key, entry): void`
Appends a `VersionEntry<T>` to the in-process history. Trims to `maxVersions` if exceeded.
### `getHistory(key): VersionEntry<T>[]`
Returns all recorded version entries for the key, oldest first.
### `getLatestVersion(key): number`
Returns the latest version number, or `0` if no history exists.
### `clearHistory(key): void`
Removes all in-process history for the key.
## Implementation Notes
- Version history is in-process only; it is not persisted to the store. On process restart, history is empty even if the store contains records with version numbers.
- `maxVersions` evicts the oldest entries from the left of the array, so `getLatestVersion` always reflects the most recent.
- The `overwrite` strategy calls `store.delete(key)` before returning, ensuring the next `set` creates a fresh record at version `1`.
- `VersionConflictError` carries both `existingFingerprint` and `newFingerprint` in its context for downstream diagnostics.

View File

@@ -0,0 +1,162 @@
# @suaveplan/job-scheduler - Universal in-process job scheduler with cron + queued runs, retries, timeouts, concurrency control, and OTel observability
## Summary
Universal in-process job scheduler with cron + queued runs, retries, timeouts, concurrency control, and OpenTelemetry observability. Built on `@suaveplan/cron` for expression parsing and next-fire computation.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job-scheduler-error](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job-execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job-scheduler-otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job-queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [job-registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cron-rescheduler](./src/scheduler/cron-rescheduler.md) — Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire fro…
- [job-dispatcher](./src/scheduler/job-dispatcher.md) — Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides *which* job…
- [job-scheduler](./src/scheduler/job-scheduler.md) — The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, ru…
- [job-snapshot](./src/scheduler/job-snapshot.md) — Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot…
<!-- end:auto-docs --> - [job-scheduler-error](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-scheduler-otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-rescheduler](./src/scheduler/cron-rescheduler.md) — Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire fro…
- [job-dispatcher](./src/scheduler/job-dispatcher.md) — Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides _which_ job…
- [job-scheduler](./src/scheduler/job-scheduler.md) — The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, ru…
- [job-snapshot](./src/scheduler/job-snapshot.md) — Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot…
- [job-scheduler-error](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-scheduler-otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-rescheduler](./src/scheduler/cron-rescheduler.md) — Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire fro…
- [job-dispatcher](./src/scheduler/job-dispatcher.md) — Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides _which_ job…
- [job-scheduler](./src/scheduler/job-scheduler.md) — The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, ru…
- [job-snapshot](./src/scheduler/job-snapshot.md) — Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot…
- [job-scheduler-error](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-scheduler-otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [job-registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-rescheduler](./src/scheduler/cron-rescheduler.md) — Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire fro…
- [job-dispatcher](./src/scheduler/job-dispatcher.md) — Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides _which_ job…
- [job-scheduler](./src/scheduler/job-scheduler.md) — The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, ru…
- [job-snapshot](./src/scheduler/job-snapshot.md) — Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot…
- [job-scheduler-error](./src/errors/job-scheduler-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_ - [events](./src/events/events.md) — <!-- begin:auto-docs --> _(no submodules)_- [execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_ - [job-execution](./src/execution/job-execution.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [job-scheduler-otel](./src/otel/job-scheduler-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_ - [job-queue](./src/queue/job-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [job-registry](./src/registry/job-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-rescheduler](./src/scheduler/cron-rescheduler.md) — Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire fro…
- [job-dispatcher](./src/scheduler/job-dispatcher.md) — Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides _which_ job…
- [job-scheduler](./src/scheduler/job-scheduler.md) — The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, ru…
- [job-snapshot](./src/scheduler/job-snapshot.md) — Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot…
## Install
```bash
bun add @suaveplan/job-scheduler
```
## Quick start
```ts
import { createJobScheduler } from "@suaveplan/job-scheduler";
const scheduler = createJobScheduler();
scheduler.schedule("@hourly", async ({ logger }) => {
logger.info("hourly heartbeat");
});
scheduler.queue(async () => {
await flushQueue();
}, { delayMs: 5_000 });
scheduler.start();
// On graceful shutdown:
await scheduler.stop();
```
## Retries + timeout
```ts
scheduler.schedule(
"*/15 * * * *",
async ({ signal, attempt }) => {
const res = await fetch("/api/sync", { signal });
if (!res.ok) throw new Error("upstream failed");
if (attempt > 1) console.warn("recovered after retry");
},
{ retries: 3, retryDelayMs: 1_000, timeout: 30_000, tz: "UTC" }
);
```
## Deterministic testing with FakeClock
```ts
import { createJobScheduler } from "@suaveplan/job-scheduler";
interface PendingTimer { fireAt: number; cb: () => void }
const state = { now: 0, nextId: 1, pending: new Map<number, PendingTimer>() };
const timers = {
setTimer: (cb: () => void, ms: number) => {
const id = state.nextId++;
state.pending.set(id, { fireAt: state.now + ms, cb });
return id;
},
clearTimer: (h: unknown) => state.pending.delete(h as number),
};
async function advance(target: number) {
while (true) {
let id: number | undefined;
let at = Infinity;
for (const [k, v] of state.pending) {
if (v.fireAt <= target && v.fireAt < at) { id = k; at = v.fireAt; }
}
if (id === undefined) break;
const t = state.pending.get(id)!;
state.pending.delete(id);
state.now = t.fireAt;
t.cb();
await Promise.resolve();
}
state.now = target;
}
const scheduler = createJobScheduler({
clock: () => state.now,
timers,
});
```
## API summary
| Method | Purpose |
| --------------------------------- | -------------------------------------------------------------------- |
| `schedule(cronExpr, fn, opts?)` | Run `fn` on every cron fire; returns jobId. |
| `queue(fn, opts?)` | One-shot run with `delayMs` or absolute `runAt`. |
| `cancel(jobId)` | Remove from queue + abort in-flight attempt. |
| `pause(jobId?)` / `resume(jobId?)`| Per-job or global pause/resume. |
| `jobs()` | Frozen `JobStatus[]` snapshot for observability. |
| `start()` | Begin the tick loop (idempotent). |
| `stop()` | Drain in-flight + stop ticking (idempotent). |
## Relationship to `@suaveplan/cron`
This package consumes `@suaveplan/cron`'s `parseCron` + `CronExpression` and is the recommended entry point for any application that needs both cron-style and ad-hoc scheduling. The cron parser stays narrow and easy to audit; this scheduler adds retries, timeouts, concurrency, lifecycle, and observability on top.
## Events
The scheduler emits the following topics on the bus passed via `JobSchedulerOptions.events`:
- `job:start` — fired before each attempt.
- `job:complete` — successful settle.
- `job:fail` — terminal failure for an attempt (retries-permitting, the next attempt fires another `job:start`).
- `job:scheduling_exhausted` — cron expression has no future fire; job is auto-cancelled.
- `job:paused` / `job:resumed` / `job:cancelled` — lifecycle transitions.
Use the `JOB_SCHEDULER_EVENTS` constants instead of literal strings to get compile-time guarantees that subscribers match emitters.
## Observability
The package records into the `@suaveplan/job-scheduler` OTel meter:
- `job_scheduler.duration_ms` (histogram, labelled `outcome` + `kind`).
- `job_scheduler.runs_total` (counter, labelled `outcome` + `kind`).
- `job_scheduler.failures_total` (counter, labelled `kind`).
- `job_scheduler.cron_exhausted_total` (counter).
- `job_scheduler.queue_depth` (histogram, sampled per tick).
`outcome ∈ { success, error, timeout }` and `kind ∈ { cron, queued }` — both closed enums to keep label cardinality bounded.

View File

@@ -0,0 +1,69 @@
# job-scheduler-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the typed error registry and `JobSchedulerError` class for `@suaveplan/job-scheduler`. The package emits exactly three of its own codes — `JOB_SCHEDULER.JOB_NOT_FOUND`, `JOB_SCHEDULER.EXECUTION_FAILED`, and `JOB_SCHEDULER.TIMEOUT`. Every other failure (cron syntax, retry exhaustion, abort) propagates with the originating package's error code unchanged so consumers can branch on the structural cause without scheduler middleware obscuring it.
## Features
- Frozen `JOB_SCHEDULER_CODES` registry built via `defineCodes` from `@suaveplan/error`.
- `JobSchedulerError` class extending `SuaveplanError<JobSchedulerErrorCode>` with runtime code validation at the constructor boundary.
- `registerJobSchedulerErrorCodes()` idempotent side-effect probe returning the registered count.
- Eager registration on module load so consumers do not need to call the registration function manually.
## Usage: Basic Example
```ts
import {
JOB_SCHEDULER_CODES,
JobSchedulerError,
} from "@suaveplan/job-scheduler";
throw new JobSchedulerError(
JOB_SCHEDULER_CODES.JOB_NOT_FOUND,
"job_scheduler.job_not_found",
{ context: { jobId: "abc-123" } }
);
```
## Usage: Advanced Example
```ts
import {
JobSchedulerError,
JOB_SCHEDULER_CODES,
registerJobSchedulerErrorCodes,
} from "@suaveplan/job-scheduler";
// Idempotent probe useful in DI containers and tree-shake audits.
const codeCount = registerJobSchedulerErrorCodes();
if (codeCount !== 3) {
throw new Error("expected exactly three JOB_SCHEDULER codes");
}
try {
// ...
} catch (err) {
if (
err instanceof JobSchedulerError &&
err.code === JOB_SCHEDULER_CODES.TIMEOUT
) {
// Surface timeout-specific UX without leaking other failure modes.
}
throw err;
}
```
## API Reference
- `JOB_SCHEDULER_CODES: { JOB_NOT_FOUND, EXECUTION_FAILED, TIMEOUT }` — frozen string-valued constants.
- `type JobSchedulerErrorCode` — union of the three values above.
- `class JobSchedulerError extends SuaveplanError<JobSchedulerErrorCode>` — constructor `(code, messageKey, options?)`; throws `TypeError` when `code` is not a registered value.
- `registerJobSchedulerErrorCodes(): number` — returns the frozen registry size (always `3`).
## Implementation Notes
The runtime guard inside the constructor is the only defence against unregistered codes once the type system is erased (e.g. across a Worker boundary, an RPC, or a `JSON.parse`). Pre-erasure consumers get a compile-time refusal from the literal union; post-erasure consumers get a `TypeError` at construction time. The package deliberately keeps the registry tiny — three codes — because every other failure mode in the scheduler has a more specific home: cron parse errors live in `CRON.*`, retry exhaustion lives in `RETRY.*`, and aborts surface as `DOMException("AbortError")` per the platform contract. Expanding this registry without a matching `proposal.md` amendment is a contract break.

View File

@@ -0,0 +1,56 @@
# events
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the closed registry of bus topic names the scheduler emits. By exporting these as a frozen `as const` object rather than literal strings scattered through call sites, consumers get compile-time guarantees that subscriber names match emitter names — a typo on either side becomes a TypeScript error, not a silent miss at runtime.
## Features
- Frozen `JOB_SCHEDULER_EVENTS` object with the canonical name for every emitted topic.
- `JobSchedulerEventName` type union derived from the constant.
- Zero runtime dependencies; tree-shakeable.
## Usage: Basic Example
```ts
import { JOB_SCHEDULER_EVENTS } from "@suaveplan/job-scheduler";
import type { EventBusLike } from "@suaveplan/types";
declare const bus: EventBusLike<Record<string, unknown>>;
bus.on(JOB_SCHEDULER_EVENTS.fail, (payload) => {
console.error("job failed", payload);
});
```
## Usage: Advanced Example
```ts
import { JOB_SCHEDULER_EVENTS, type JobSchedulerEventName } from "@suaveplan/job-scheduler";
function subscribeAll(
bus: EventBusLike<Record<string, unknown>>,
fn: (name: JobSchedulerEventName, payload: unknown) => void
): () => void {
const offs: Array<() => void> = [];
for (const name of Object.values(JOB_SCHEDULER_EVENTS)) {
offs.push(bus.on(name, (p) => fn(name, p)));
}
return () => {
for (const off of offs) off();
};
}
```
## API Reference
- `JOB_SCHEDULER_EVENTS` — frozen `{ start, complete, fail, schedulingExhausted, paused, resumed, cancelled }` constant.
- `type JobSchedulerEventName` — union of the values above.
## Implementation Notes
The map deliberately keys by short camelCase (`schedulingExhausted`) but values are the snake/colon-cased wire names (`"job:scheduling_exhausted"`) the bus actually emits. This split lets call sites read fluently — `JOB_SCHEDULER_EVENTS.fail` is more obvious than `"job:fail"` — while keeping the wire format stable for downstream tooling (log aggregators, dashboards) that grep on the exact emitted string. The `as const` plus `Object.freeze` combination is required: `as const` gives literal types at compile time, and `Object.freeze` prevents mutation at runtime.
Why a flat constant rather than a union of literal strings? Because callers should be able to programmatically iterate over every emitted topic — see the advanced example above where `Object.values(JOB_SCHEDULER_EVENTS)` is used to subscribe to all of them at once. A pure union type would force callers to either hard-code the list (drift hazard) or use an external array of strings (duplication). The constant is the single source of truth that everything else derives from, including the `JobSchedulerEventName` union. Note also that the registry is intentionally small — every new event added here must come with a matching entry in `JobSchedulerEventMap` and a corresponding emitter inside the scheduler, so the contract stays tight and consumers can rely on the registry being exhaustive.

View File

@@ -0,0 +1,91 @@
# job-execution
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Runs a single job from start to terminal outcome with full retry, timeout, and abort semantics. The scheduler delegates per-job execution to this module so the scheduler core stays focused on ordering and concurrency — every detail of "what happens when a handler runs" lives here. The function emits the canonical `job:start` / `job:complete` / `job:fail` events and records the OTel histograms + counters via the colocated `otel` module.
## Features
- Per-attempt `AbortController` linked to the caller's cancel signal AND to an optional timeout deadline.
- Retry loop delegated to `@suaveplan/retry.retry()` with a `createConstantBackoff({ delay: retryDelayMs })` strategy and the scheduler's injected `timers` threaded through.
- Closed event-bus contract (`job:start`, `job:complete`, `job:fail`).
- Per-attempt OTel records, with `timeout` distinguished from `error` in the outcome label.
- `onAttemptController` hook so the scheduler can `cancel()` the in-flight attempt at any time.
## Usage: Basic Example
```ts
import { executeJob } from "@suaveplan/job-scheduler";
import { EventBus } from "@suaveplan/events";
const events = new EventBus();
const controller = new AbortController();
await executeJob({
jobId: "report-1",
kind: "queued",
handler: async ({ logger }) => {
logger.info("running");
},
scheduledAt: Date.now(),
retries: 0,
retryDelayMs: 0,
cancelSignal: controller.signal,
clock: () => Date.now(),
logger: { debug() {}, info() {}, warn() {}, error() {} },
events,
timers: {
setTimer: (cb, ms) => globalThis.setTimeout(cb, ms),
clearTimer: (h) => globalThis.clearTimeout(h as number),
},
});
```
## Usage: Advanced Example
```ts
import { executeJob, JobSchedulerError, JOB_SCHEDULER_CODES } from "@suaveplan/job-scheduler";
let activeController: AbortController | undefined;
try {
await executeJob({
jobId: "flaky",
kind: "queued",
handler: async (ctx) => {
if (ctx.attempt < 3) throw new Error("transient");
},
scheduledAt: 0,
retries: 5,
retryDelayMs: 10,
timeoutMs: 1_000,
cancelSignal: outerController.signal,
clock,
logger,
events,
timers,
onAttemptController: (c) => {
activeController = c;
},
});
} catch (err) {
if (err instanceof JobSchedulerError && err.code === JOB_SCHEDULER_CODES.TIMEOUT) {
// ...
}
throw err;
}
```
## API Reference
- `executeJob(input: ExecuteJobInput): Promise<void>` — orchestrates the full retry/timeout/abort loop.
- `interface JobContext { jobId, signal, attempt, scheduledAt, logger }`.
- `type JobHandler = (ctx: JobContext) => Promise<void>`.
- `type ExecutionTimers = TimersLike` — aliased to the cross-package `TimersLike` contract from `@suaveplan/types`.
- `interface ExecuteJobInput` — see source for the full set of fields (handler, kind, retries, retryDelayMs, timeoutMs, cancelSignal, clock, logger, events, timers, onAttemptController).
## Implementation Notes
The retry loop is delegated to `@suaveplan/retry.retry()` now that the retry primitive accepts an injected `TimersLike` pair through `RetryConfig.timers` — the scheduler threads its own `timers` straight through so FakeClock-driven test runs stay deterministic. The two abort sources (caller cancel + per-attempt timeout) are merged via a single per-attempt `AbortController` inside the retry callback so handlers see one signal, not two. The `onAttemptController` hook is exposed so the scheduler can mark the in-flight controller for an immediate `cancel(jobId)` — without it, the scheduler would need to read internal state from this module across abstraction boundaries. `RetryExhaustedError` is translated to `JOB_SCHEDULER.EXECUTION_FAILED` preserving the underlying error in `cause`; timeout errors are unwrapped and re-thrown directly so callers see the typed `JOB_SCHEDULER.TIMEOUT` they expect.

View File

@@ -0,0 +1,76 @@
# job-scheduler-otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wires `@opentelemetry/api` metrics for the scheduler. The module exposes a small set of recording helpers — `recordJobRun`, `recordJobDuration`, `incrementJobFailure`, `incrementCronExhausted`, `recordQueueDepth` — that the scheduler and execution modules call at fixed points. Lazy meter / instrument initialisation keeps cold-start cost near zero for callers that never produce metrics.
## Features
- One meter (`@suaveplan/job-scheduler`) shared across all instruments.
- Lazy-initialised `Histogram`s and `Counter`s — no instrument is created until first use.
- Closed-enum `outcome` and `kind` label values (`success | error | timeout`, `cron | queued`) so cardinality is bounded.
- Pure side-effect-free getters — the module can be imported in tree-shake audits without producing instruments.
## Usage: Basic Example
```ts
import { recordJobRun } from "@suaveplan/job-scheduler";
const start = Date.now();
try {
await runHandler();
recordJobRun(Date.now() - start, "success", "queued");
} catch (err) {
recordJobRun(Date.now() - start, "error", "queued");
throw err;
}
```
## Usage: Advanced Example
```ts
import {
incrementCronExhausted,
incrementJobFailure,
recordJobDuration,
recordJobRun,
recordQueueDepth,
} from "@suaveplan/job-scheduler";
function tick(queueDepth: number): void {
recordQueueDepth(queueDepth);
}
function onCronExhausted(): void {
incrementCronExhausted();
}
function onJobFinished(
durationMs: number,
outcome: "success" | "error" | "timeout",
kind: "cron" | "queued"
): void {
recordJobRun(durationMs, outcome, kind);
if (outcome !== "success") incrementJobFailure(kind);
if (outcome === "timeout") {
recordJobDuration(durationMs, "timeout", kind);
}
}
```
## API Reference
- `type JobOutcome = "success" | "error" | "timeout"`
- `type JobKind = "cron" | "queued"`
- `recordJobRun(durationMs, outcome, kind): void` — records duration histogram sample and increments runs counter.
- `recordJobDuration(durationMs, outcome, kind): void` — duration-only sample, no counter bump.
- `incrementJobFailure(kind): void` — bump failures counter by `kind`.
- `incrementCronExhausted(): void` — bump exhausted-cron counter.
- `recordQueueDepth(depth): void` — sample current heap depth.
## Implementation Notes
All instruments are created exactly once per process via lazy module-scope caches. The OpenTelemetry `metrics` API guarantees stable instrument identity across `getMeter(name).createX` calls only when name + kind + description + unit match — caching here avoids that contract entirely. Label-value enums are closed by TypeScript at the boundary; passing a free-form string would compile-fail. The histograms accept `number` durations; callers compute deltas with the injected scheduler `Clock` (test runs use `FakeClock` so values are deterministic and stable across CI runs).

View File

@@ -0,0 +1,72 @@
# job-queue
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A minimal array-backed binary min-heap that orders `QueuedJob` entries by their `runAt` epoch-ms timestamp. It is the data structure the `JobScheduler` tick loop consults to decide what to run next — `peek` to check whether the earliest job is due, then `dequeue` to claim it. The implementation deliberately has no external dependency so the package's universal bundle stays small and free of polyfill drift.
## Features
- O(log n) `enqueue` and `dequeue`.
- O(1) `peek` and `size`.
- O(n) `remove(jobId)` with worst-case single swap + bidirectional sift.
- Snapshot helper for diagnostics and property tests.
- No external deps; pure TypeScript; tree-shakeable.
## Usage: Basic Example
```ts
import { JobQueue } from "@suaveplan/job-scheduler";
const q = new JobQueue();
q.enqueue({ jobId: "a", runAt: 3 });
q.enqueue({ jobId: "b", runAt: 1 });
q.enqueue({ jobId: "c", runAt: 2 });
while (q.size() > 0) {
const job = q.dequeue();
if (job !== undefined) console.log(job.jobId, job.runAt);
}
// Logs: b 1, c 2, a 3
```
## Usage: Advanced Example
```ts
import { JobQueue } from "@suaveplan/job-scheduler";
const q = new JobQueue();
q.enqueue({ jobId: "cron-1", runAt: 1000, cronExpr: "*/5 * * * *" });
q.enqueue({ jobId: "queued-1", runAt: 2500 });
q.enqueue({ jobId: "queued-2", runAt: 1500 });
// Cancel a job that hasn't run yet.
const removed = q.remove("queued-2");
console.log(removed); // true
// Peek does NOT mutate.
const next = q.peek();
console.log(next?.jobId); // "cron-1"
// snapshot() returns a frozen heap-order view, useful for property tests
// and observability dumps without exposing the internal array.
const view = q.snapshot();
console.log(view.length); // 2
```
## API Reference
- `class JobQueue`
- `enqueue(job: QueuedJob): void`
- `dequeue(): QueuedJob | undefined`
- `peek(): QueuedJob | undefined`
- `size(): number`
- `remove(jobId: string): boolean`
- `snapshot(): readonly QueuedJob[]`
- `interface QueuedJob { jobId: string; runAt: number; cronExpr?: string }`
## Implementation Notes
A linear scan inside `remove` is acceptable because cancellations are rare relative to ticks — most live jobs run to completion and the scheduler reschedules them by `enqueue`, never `remove`. Switching to an O(log n) cancellation scheme (a parallel `Map<jobId, heapIndex>` with index-tracking on every swap) doubles the per-operation constant factor for the common path; we prefer the smaller and obviously-correct linear scan. The bidirectional sift after the swap-with-last is required because the replacement entry may be greater OR lesser than its new parent — one of the two sifts will be a no-op, but skipping it would leave the heap invariant broken in the cases where the removed slot was a left descendant of a smaller sibling.

View File

@@ -0,0 +1,73 @@
# job-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a string-to-jobId binding so application code can refer to scheduled jobs by stable name (`"nightly-cleanup"`, `"hourly-report"`) without leaking the scheduler's internal id generator (`crypto.randomUUID()` by default). The registry is a thin `Map` wrapper with explicit semantics for duplicate names — it throws rather than silently overwriting, because silent overwrite has been a recurring source of "ghost job" bugs in similar systems where a redeploy registers the same name twice and the original job leaks.
## Features
- `register(name, jobId)` — throws on duplicate name with a typed `JobSchedulerError`.
- `unregister(name)` — returns boolean for unambiguous removal semantics.
- `resolve(name) -> jobId | undefined` — pure lookup, never throws.
- `list()` — frozen snapshot for diagnostics and admin endpoints.
- Zero dependencies beyond the package's own error class.
## Usage: Basic Example
```ts
import { JobRegistry } from "@suaveplan/job-scheduler";
const registry = new JobRegistry();
registry.register("nightly", "uuid-1");
console.log(registry.resolve("nightly")); // "uuid-1"
registry.unregister("nightly");
```
## Usage: Advanced Example
```ts
import {
createJobScheduler,
JobRegistry,
JobSchedulerError,
JOB_SCHEDULER_CODES,
} from "@suaveplan/job-scheduler";
const scheduler = createJobScheduler();
const registry = new JobRegistry();
const jobId = scheduler.schedule("0 * * * *", async () => {
// ...
});
registry.register("hourly-report", jobId);
try {
registry.register("hourly-report", "different-id");
} catch (err) {
if (
err instanceof JobSchedulerError &&
err.code === JOB_SCHEDULER_CODES.JOB_NOT_FOUND
) {
// Duplicate-name guard tripped — surface a CLI-friendly message.
}
}
for (const { name, jobId } of registry.list()) {
console.log(name, "->", jobId);
}
```
## API Reference
- `class JobRegistry`
- `register(name: string, jobId: string): void` — throws `JobSchedulerError` on duplicate name.
- `unregister(name: string): boolean` — `true` when removed, `false` when unknown.
- `resolve(name: string): string | undefined` — pure lookup.
- `list(): ReadonlyArray<{ name: string; jobId: string }>` — frozen snapshot.
## Implementation Notes
The duplicate-name branch reuses `JOB_SCHEDULER.JOB_NOT_FOUND` rather than introducing a fourth registry code. The decision is deliberate: the registry is a 12-line wrapper over `Map`, and growing the error registry just for it would couple the package's public contract to a peripheral feature. The `context` payload on the thrown error includes the conflicting `name`, so consumers branching on the duplicate-name case can recognise it without ambiguity. The registry is not synchronised — concurrent registration from two async tasks may race; if you need atomicity, wrap it in a `Mutex` from `@suaveplan/timing`.

View File

@@ -0,0 +1,61 @@
# cron-rescheduler
## Purpose
Houses the two cron-related decisions the scheduler makes after a job settles or resumes: (1) compute the next fire from the cron expression, OR (2) recognise that the expression has no future fire and gracefully cancel the job. The module also implements optional catchup-missed-fires logic for resume-after-pause scenarios. Extracting this here keeps the scheduler core small and isolates the only place where `CRON.NEXT_RUN_NOT_FOUND` is consumed.
## Features
- `reenqueueCron(job, env, tz)` — pushes the next fire onto the queue, or emits `job:scheduling_exhausted` once and marks the job cancelled when the expression is exhausted.
- `applyCatchupAndReenqueue(...)` — enqueues up to `maxMissedRuns` past fires (those at `now`-or-later are skipped) before re-enqueuing the next future fire.
- Closed `ReenqueueOutcome = "scheduled" | "exhausted"` return for caller branching.
- OTel `cron_exhausted_total` counter is incremented exactly once per exhausted job.
## Usage: Basic Example
```ts
import { reenqueueCron } from "@suaveplan/job-scheduler";
const outcome = reenqueueCron(job, env, "UTC");
if (outcome === "exhausted") {
// job has been marked cancelled; bus has emitted the event
}
```
## Usage: Advanced Example
```ts
import {
applyCatchupAndReenqueue,
reenqueueCron,
} from "@suaveplan/job-scheduler";
function onResume(job: ScheduledJob): void {
if (job.kind !== "cron") return;
const outcome = applyCatchupAndReenqueue(
job,
env,
job.tz ?? "UTC",
/* catchupMissed */ true,
/* maxMissedRuns */ 3
);
if (outcome === "exhausted") {
// log + surface to ops dashboard
}
}
function onComplete(job: ScheduledJob): void {
reenqueueCron(job, env, job.tz ?? "UTC");
}
```
## API Reference
- `type ReenqueueOutcome = "scheduled" | "exhausted"`.
- `interface CronEnv { clock, events, queue, expressionOf }`.
- `reenqueueCron(job, env, tz): ReenqueueOutcome`.
- `applyCatchupAndReenqueue(job, env, tz, catchupMissed, maxMissedRuns): ReenqueueOutcome`.
## Implementation Notes
`expressionOf` is a thin lookup callback rather than a direct map reference so the scheduler can keep the `CronExpression` cache private — exposing it as a parameter would tempt callers to mutate the cache by side channel. The catchup branch uses `expression.nextRuns(maxMissedRuns, lastRun, tz)` from `@suaveplan/cron` rather than rolling its own iteration, ensuring DST transitions and timezone edge cases are handled identically to the rest of the cron stack. Errors other than `CRON.NEXT_RUN_NOT_FOUND` rethrow — they indicate a programming bug (e.g., an invalid tz that somehow slipped past validation) and must surface, not be swallowed.

View File

@@ -0,0 +1,67 @@
# job-dispatcher
## Purpose
Encapsulates the "hand a job to the executor" step of the scheduler's tick loop. The scheduler core decides *which* job runs next and *when*; this module is responsible for the *how* — wiring the per-attempt `AbortController`, tracking the in-flight promise, and updating per-job counters (`runs`, `failures`, `lastRun`) on settle. Splitting this out keeps the scheduler core under the 300-line file budget and isolates the small but error-prone bookkeeping work in one focused module.
## Features
- Single `dispatch(job, scheduledAt, env)` function — no class, no state of its own.
- Captures the in-flight promise into the scheduler-owned `Set<Promise>` for `stop()` drain.
- Atomically transitions the job to `running` and registers a fresh `AbortController` on the job record.
- Invokes the scheduler's `afterRun` callback in `finally`, regardless of success or failure.
## Usage: Basic Example
```ts
import { dispatch } from "@suaveplan/job-scheduler";
dispatch(scheduledJob, scheduledAt, {
clock,
logger,
events,
timers,
inFlight,
runningSet,
afterRun: (job) => {
// scheduler decides whether to re-enqueue or remove
},
});
```
## Usage: Advanced Example
```ts
import { dispatch, type ScheduledJob } from "@suaveplan/job-scheduler";
function pump(jobs: Iterable<ScheduledJob>): void {
for (const job of jobs) {
if (job.status !== "pending") continue;
dispatch(job, job.nextRun ?? clock(), {
clock,
logger,
events,
timers,
inFlight,
runningSet,
afterRun: (j) => {
if (j.kind === "queued") {
// one-shot — let the scheduler garbage-collect
return;
}
// re-enqueue cron jobs by computing the next fire
},
});
}
}
```
## API Reference
- `dispatch(job: ScheduledJob, scheduledAt: number, env: DispatchEnv): void` — fires the job. No return value; all bookkeeping happens via `env`.
- `interface ScheduledJob` — internal record shape mutated in place.
- `interface DispatchEnv` — `{ clock, logger, events, timers, inFlight, runningSet, afterRun }`.
## Implementation Notes
The `afterRun` callback is the scheduler's hook for "what happens once the job settles" — for queued jobs the scheduler typically deletes the entry; for cron jobs it computes the next `nextRun` and re-enqueues. Putting this branch in the scheduler core (rather than passing a flag to `dispatch`) keeps `dispatch` polymorphism-free and makes the lifecycle of every cron / queued job obvious at the call site. The promise is tracked in `env.inFlight` so `JobScheduler.stop()` can `Promise.allSettled` over the set and drain the in-flight work cleanly.

View File

@@ -0,0 +1,86 @@
# job-scheduler
## Purpose
The `JobScheduler` class is the package's main entry point: it accepts both cron-scheduled and one-shot queued jobs, runs them through a single tick loop with bounded concurrency, and exposes lifecycle controls (`start`, `stop`, `pause`, `resume`, `cancel`) plus an observability snapshot via `jobs()`. Cron parsing is delegated to `@suaveplan/cron` so the scheduler never embeds its own expression parser. The injectable `Clock`, `timers`, `events`, and `generateId` make every test run fully deterministic without monkey-patching globals.
## Features
- Cron-scheduled jobs via `schedule(cronExpr, handler, options)`.
- One-shot queued jobs via `queue(handler, options)` with `delayMs` or absolute `runAt`.
- Bounded `concurrency` enforced by the tick loop.
- Per-job `retries`, `retryDelayMs`, and `timeout` honoured by the execution module.
- `pause` / `resume` at job level OR globally.
- `cancel` removes from queue AND aborts the in-flight attempt.
- Optional `catchupMissed` replay of past fires on resume, bounded by `maxMissedRuns`.
- Graceful drain on `stop()` — every in-flight promise is awaited.
- Cron-expression exhaustion handled via `job:scheduling_exhausted` event and `cron_exhausted_total` counter.
## Usage: Basic Example
```ts
import { createJobScheduler } from "@suaveplan/job-scheduler";
const scheduler = createJobScheduler();
scheduler.schedule("*/5 * * * *", async ({ logger }) => {
logger.info("five-minute heartbeat");
});
scheduler.start();
// later
await scheduler.stop();
```
## Usage: Advanced Example
```ts
import { createJobScheduler, JobRegistry } from "@suaveplan/job-scheduler";
import { EventBus } from "@suaveplan/events";
const events = new EventBus();
events.on("job:fail", ({ jobId, error }) => {
console.error("job failed", jobId, error);
});
const registry = new JobRegistry();
const scheduler = createJobScheduler({
concurrency: 10,
events,
catchupMissed: true,
maxMissedRuns: 3,
});
const id = scheduler.schedule(
"0 * * * *",
async ({ signal, attempt }) => {
await fetch("/sync", { signal });
if (attempt > 1) console.warn("recovered after retry");
},
{ retries: 2, retryDelayMs: 500, timeout: 30_000, tz: "UTC" }
);
registry.register("hourly-sync", id);
const adHoc = scheduler.queue(
async () => {
// one-shot job
},
{ delayMs: 5_000 }
);
scheduler.start();
```
## API Reference
- `class JobScheduler`
- `schedule(cronExpr, handler, options?): string` — parses via `@suaveplan/cron`; returns jobId.
- `queue(handler, options?): string` — schedules a one-shot run.
- `cancel(jobId): boolean` — cancels the job; aborts in-flight attempt if any.
- `pause(jobId?): void` / `resume(jobId?): void` — per-job or global.
- `jobs(): readonly JobStatus[]` — frozen snapshot.
- `start(): void` — idempotent.
- `stop(): Promise<void>` — drains every in-flight job before resolving.
- `createJobScheduler(options?): JobScheduler` — factory convenience.
## Implementation Notes
The tick loop is driven by the injected `timers.setTimer` rather than `setInterval` so a slow tick cannot pile up overlapping invocations — every tick schedules its successor in `finally`. The cron-exhaustion branch deliberately emits exactly one event per exhausted job by marking the status `cancelled` before re-entering `afterRun`. The `expressions` cache is private and only reachable through `cronEnv.expressionOf` so the rescheduler helper module cannot mutate it. `stop()` aborts every in-flight controller before awaiting `Promise.allSettled` so handlers that respect `ctx.signal` short-circuit rather than running to completion under shutdown.

View File

@@ -0,0 +1,45 @@
# job-snapshot
## Purpose
Projects a mutable `ScheduledJob` record (the scheduler's internal data structure) to an immutable `JobStatus` snapshot for public consumption via `JobScheduler.jobs()`. Extracted into its own module so the scheduler core stays under the 300-line hard limit and so the shape transformation has a single obvious home.
## Features
- Pure function — no side effects, no allocation beyond the returned object.
- Honours `exactOptionalPropertyTypes` by conditionally spreading optional fields.
- Output object is safe to freeze at the caller.
## Usage: Basic Example
```ts
import { snapshotJob } from "@suaveplan/job-scheduler";
const status = snapshotJob(internalJobRecord);
console.log(status.id, status.status, status.runs);
```
## Usage: Advanced Example
```ts
import { snapshotJob } from "@suaveplan/job-scheduler";
function adminDashboard(jobs: Iterable<ScheduledJob>): JobStatus[] {
const out: JobStatus[] = [];
for (const j of jobs) {
const snap = snapshotJob(j);
if (snap.failures > 0) out.push(snap);
}
return out;
}
```
## API Reference
- `snapshotJob(job: ScheduledJob): JobStatus` — returns a plain object with every public field of `JobStatus`.
## Implementation Notes
The conditional spread (`...(value !== undefined ? { key: value } : {})`) is required because the repo TSConfig sets `exactOptionalPropertyTypes: true` — assigning `undefined` to an optional field is a compile-time error. Inline-spreading a guard object is the smallest pattern that preserves the omit-vs-undefined distinction. The function deliberately does not freeze its return value; the caller decides whether to freeze the whole array or each individual entry.
Keeping this projection in its own module rather than inlining inside the scheduler class has two side benefits: first, the scheduler core stays under the 300-line file budget that the repo enforces; second, unit-testing the projection in isolation is trivial because every input field can be exercised without spinning up a full scheduler, a queue, and an event bus. The trade-off — a single extra import in `job-scheduler.ts` — is negligible, especially because the function is small enough that the bundler inlines it during tree-shaking anyway. When new optional fields are added to `JobStatus`, the only place that needs updating is this file plus the `JobStatus` type definition, which is exactly where the change should live.

View File

@@ -0,0 +1,75 @@
# @suaveplan/queue-distribution-http - HTTP transport plugin for @suaveplan/queue-distribution: HttpWorkerCommunicator (fetch-backed, Zod-validated wire envelope) and RemoteWorker (AbstractWorker subclass with @suaveplan/retry-backed connect / reconnect / health loop).
## Summary
HTTP transport plugin for `@suaveplan/queue-distribution`. Ships `HttpWorkerCommunicator` (a fetch-backed `WorkerCommunicator` with a Zod-validated wire envelope, injectable `fetch`, structured timeouts, and AbortSignal threading) and `RemoteWorker` (an `AbstractWorker` subclass driving the communicator with bounded exponential-backoff connect retries via `@suaveplan/retry`).
The plugin lives separately from the core distribution package so browser-only and worker-thread-only consumers don't have to carry HTTP transport code they never use.
<!-- begin:auto-docs -->
## Documentation tree
- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue-distribution-http-error](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-http-error](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-http-error](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-http-error](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/communicator/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [http-communicator](./src/communicator/http/http-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue-distribution-http-error](./src/errors/queue-distribution-http-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_ - [remote](./src/worker/remote/remote.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/queue-distribution-http
```
## Quick start
```ts
import { WorkerPool, WorkerManager } from "@suaveplan/queue-distribution";
import {
HttpWorkerCommunicator,
RemoteWorker,
} from "@suaveplan/queue-distribution-http";
const communicator = new HttpWorkerCommunicator({
defaultTimeoutMs: 5_000,
headers: { Authorization: `Bearer ${token}` },
});
const pool = new WorkerPool();
pool.addWorker(new RemoteWorker("https://worker-a.example", communicator, { id: "a" }));
pool.addWorker(new RemoteWorker("https://worker-b.example", communicator, { id: "b" }));
await pool.startAllWorkers();
const manager = new WorkerManager(pool, {
autoHealthCheck: false,
maxTaskRetries: 3,
});
const result = await manager.assignTask(task, input);
```
## Modules
- [Errors](./src/errors/queue-distribution-http-error.md) — `QueueDistributionHttpError` + 6 stable `HTTP.*` codes.
- [WorkerCommunicator base](./src/communicator/base/base.md) — interface + `AbstractWorkerCommunicator`.
- [HttpWorkerCommunicator](./src/communicator/http/http-communicator.md) — fetch-backed transport.
- [RemoteWorker](./src/worker/remote/remote.md) — connect / reconnect / health loop.
- [Observability](./src/observability/instruments.md) — OTel counters, histograms, tracers.
## Design highlights
- 100% test coverage (statements + branches + functions + lines).
- Injectable `fetch` and `Clock` — deterministic under test, swappable for auth-wrapped fetch in production.
- Connect retries routed through `@suaveplan/retry`'s `sleep`; bounded loop, no recursion.
- Zod-validated wire envelope rejects malformed payloads at the boundary.
- Structured `QueueDistributionHttpError` subclasses with a frozen stable-code registry.
## Status
Part of the rebuild — version `0.1.0` while it stabilises.

View File

@@ -0,0 +1,80 @@
# `WorkerCommunicator` + `AbstractWorkerCommunicator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkerCommunicator` is the structural contract every transport implementation in `@suaveplan/queue-distribution-http` (and future RPC transports outside this package) implements. `AbstractWorkerCommunicator` carries the shared bookkeeping — default-timeout handling, header carriage, JSON serialise/deserialise helpers, and the `withTimeout` race used by every send/check call — so concrete subclasses focus on wire-level details. Stateless transports get connect/disconnect no-op defaults.
## Features
- `sendTask<I, O>(endpoint, task, input, signal?)` — preserves `<I, O>` end-to-end.
- `checkStatus`, `checkHealth`, `ping`, `connect`, `disconnect` — full transport surface.
- `getProtocol(): string` — short identifier for logs and capabilities.
- `withTimeout(operation, promise, timeoutMs?)` — Promise.race against an injectable timeout that rejects with `HTTP.TIMEOUT`.
- Configurable `defaultTimeoutMs` and per-request `headers`.
- Injectable `Clock` for tests.
## Usage: Basic Example
```ts
import {
AbstractWorkerCommunicator,
type WorkerCommunicator,
} from "@suaveplan/queue-distribution-http/communicator/base/base";
import type {
DistributedTask,
WorkerHealth,
WorkerStatusValue,
} from "@suaveplan/queue-distribution";
class EchoCommunicator extends AbstractWorkerCommunicator {
async sendTask<I, O>(_e, _t, input: I): Promise<O> {
return input as unknown as O;
}
async checkStatus(): Promise<WorkerStatusValue> {
return "ONLINE";
}
async checkHealth(): Promise<WorkerHealth> {
return {
isHealthy: true,
metrics: { cpu: 0, memory: 0, tasksFailed: 0, tasksSucceeded: 0, responseTime: 0 },
};
}
async ping(): Promise<number> {
return 0;
}
getProtocol(): string {
return "echo";
}
}
```
## Usage: Advanced Example
Subclasses can ride on `withTimeout` to enforce a per-call deadline without duplicating timer plumbing.
```ts
import { AbstractWorkerCommunicator } from "@suaveplan/queue-distribution-http/communicator/base/base";
class SlowCommunicator extends AbstractWorkerCommunicator {
async sendTask(): Promise<unknown> {
return this.withTimeout("sendTask", longRunningRpc(), 2_000);
}
// … rest of interface …
}
```
## API Reference
- `interface WorkerCommunicator` — public surface.
- `abstract class AbstractWorkerCommunicator implements WorkerCommunicator`.
- `constructor(options?: { defaultTimeoutMs?, headers? }, deps?: { clock? })`.
- `protected async withTimeout<T>(operation, promise, timeoutMs?): Promise<T>`.
- `protected serialize(value): string`, `protected deserialize<T>(s): T`.
- `connect`, `disconnect` — overridable no-ops.
## Implementation Notes
`withTimeout` attaches a stateful `then` handler to the source promise so we can `clearTimeout` from the resolution path, and races against a deferred `timeoutSignal`. This avoids the unhandled-rejection trap of using a freestanding `setTimeout(..., reject)` Promise alongside `Promise.race`: the source promise's settlement always clears the timer before the rejection can fire late. Non-Error rejections from `promise` are wrapped in `new Error(String(err))` so the error chain stays well-typed for downstream `SuaveplanError` consumers.

View File

@@ -0,0 +1,65 @@
# `HttpWorkerCommunicator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HttpWorkerCommunicator` is the `WorkerCommunicator` implementation for talking to a remote worker over HTTP(S). It uses an injectable `fetch` (defaulting to `globalThis.fetch`) so tests can drive deterministic request/response sequences without an actual server. Every response is validated against a Zod envelope (`{ success: true, data }` | `{ success: false, error }`) so malformed payloads are rejected at the boundary rather than smuggled in as `unknown` data.
## Features
- Endpoints used: `POST /execute`, `GET /status`, `GET /health`, `GET /ping`.
- Zod-validated wire envelope (`workerApiResponseSchema(dataSchema)`).
- Structured error mapping: `RESPONSE_MALFORMED` for envelope mismatch, `PROTOCOL_ERROR` for upstream-reported failures and non-2xx with a successful envelope, `REQUEST_FAILED` for network rejections including `AbortError`.
- Configurable headers and `defaultTimeoutMs`; injectable `fetch` and `Clock`.
- OTel histogram + counter per request, dimensioned by `outcome`.
- AbortSignal threaded into `fetch.init.signal`.
- `getProtocol()` returns `"http"`.
## Usage: Basic Example
```ts
import { HttpWorkerCommunicator } from "@suaveplan/queue-distribution-http/communicator/http/http-communicator";
const communicator = new HttpWorkerCommunicator({
defaultTimeoutMs: 5_000,
headers: { "X-Tenant": "acme" },
});
const result = await communicator.sendTask("https://worker.example", task, input);
```
## Usage: Advanced Example
Inject a deterministic `fetch` for tests, or wrap it with a custom auth + retry layer in production.
```ts
import { HttpWorkerCommunicator } from "@suaveplan/queue-distribution-http/communicator/http/http-communicator";
const communicator = new HttpWorkerCommunicator(
{ defaultTimeoutMs: 10_000, headers: { Authorization: `Bearer ${token}` } },
{
fetch: async (url, init) => {
const response = await globalThis.fetch(url, {
...init,
credentials: "include",
});
return response;
},
},
);
```
## API Reference
- `class HttpWorkerCommunicator extends AbstractWorkerCommunicator`.
- `constructor(options?: HttpWorkerCommunicatorOptions, deps?: HttpWorkerCommunicatorDeps)`.
- `interface HttpWorkerCommunicatorOptions` — `defaultTimeoutMs?`, `headers?`.
- `interface HttpWorkerCommunicatorDeps` — `clock?`, `fetch?`.
- `type FetchLike` — minimal `fetch` shape (`ok`, `status`, `statusText`, `json()`).
- `function workerApiResponseSchema<T>(dataSchema)` — produces the Zod envelope for any payload shape.
## Implementation Notes
The wire envelope is a discriminated union (`success: true` carries `data`, `success: false` carries `error`). The communicator treats non-2xx HTTP status as a protocol error even when the envelope says `success: true` — that protects against misbehaving servers returning 500 with a "this somehow worked" payload. Headers are spread at request time so per-call overrides are not yet supported; if needed, subclass and override `#makeRequest`.

View File

@@ -0,0 +1,80 @@
# `QueueDistributionHttpError` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`QueueDistributionHttpError` is the base class for every structured error thrown by `@suaveplan/queue-distribution-http`. Each subclass carries a stable code from the registry produced by `defineCodes("HTTP", [...])`. Consumers programmatically match on `error.code` to distinguish network failures from upstream protocol errors from timeouts — the registry is exhaustive and every code is thrown by at least one production code path.
## Features
- 6 stable codes — `REQUEST_FAILED`, `PROTOCOL_ERROR`, `TIMEOUT`, `RESPONSE_MALFORMED`, `CONNECT_FAILED`, `UNREACHABLE`.
- One concrete subclass per code with a typed constructor signature.
- Optional `cause` narrowed to `Error` instances; non-Error causes drop silently.
- Compatible with `SuaveplanError` cause-chain traversal.
## Usage: Basic Example
```ts
import {
HTTP_CODES,
QueueDistributionHttpError,
} from "@suaveplan/queue-distribution-http/errors/queue-distribution-http-error";
try {
await communicator.sendTask("http://w", task, input);
} catch (error: unknown) {
if (
error instanceof QueueDistributionHttpError &&
error.code === HTTP_CODES.PROTOCOL_ERROR
) {
// Upstream worker reported a structured failure — surface it.
}
throw error;
}
```
## Usage: Advanced Example
Combine with `@suaveplan/error` cause-chain helpers to extract underlying network failures.
```ts
import {
HTTP_CODES,
HttpRequestFailedError,
QueueDistributionHttpError,
} from "@suaveplan/queue-distribution-http/errors/queue-distribution-http-error";
function classify(error: unknown): "network" | "protocol" | "unknown" {
if (!(error instanceof QueueDistributionHttpError)) {
return "unknown";
}
if (error.code === HTTP_CODES.REQUEST_FAILED) {
return "network";
}
if (
error.code === HTTP_CODES.PROTOCOL_ERROR ||
error.code === HTTP_CODES.RESPONSE_MALFORMED
) {
return "protocol";
}
return "unknown";
}
```
## API Reference
- `const HTTP_CODES` — frozen registry of `HTTP.*` codes.
- `type QueueDistributionHttpErrorCode` — union of every code.
- `class QueueDistributionHttpError extends SuaveplanError<QueueDistributionHttpErrorCode>`.
- `class HttpRequestFailedError({ url, cause? })`.
- `class HttpProtocolError({ url, status, upstreamCode, upstreamMessage, cause? })`.
- `class HttpTimeoutError({ operation, timeoutMs })`.
- `class HttpResponseMalformedError({ url, cause? })`.
- `class HttpConnectFailedError({ endpoint, attempts, cause? })`.
- `class HttpUnreachableError({ endpoint, cause? })`.
## Implementation Notes
`HttpProtocolError` carries the upstream `code` and `message` in its formatted message so logs and dashboards can group failures by the remote worker's own error vocabulary without parsing JSON. `HttpUnreachableError` exists as a documented "this is the failure-class" signal — the error is allocated and discarded inside `RemoteWorker.#handleCommunicationError` because the worker's reaction (markUnhealthy + reconnect) is the actual recovery; the error class is here so future call sites (e.g. observability dashboards) can match on the registered code.

View File

@@ -0,0 +1,59 @@
# HTTP plugin OTel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`observability/instruments.ts` centralises every OpenTelemetry counter, histogram, and tracer emitted by `@suaveplan/queue-distribution-http`. Instruments are registered under the meter / tracer name `"@suaveplan/queue-distribution-http"` so consumers configuring an SDK can route them with a single rule. Tracer / meter handles are cached behind `resetInstrumentCache()` so tests can swap providers between assertions.
## Features
- Counters: `queue_dist_http.request_total{outcome}`, `queue_dist_http.connect.attempts_total{outcome}`.
- Histogram: `queue_dist_http.request.duration_ms{outcome}`.
- Tracers: `queue_dist_http.sendTask`, `queue_dist_http.connect`.
- `resetInstrumentCache()` for tests that install a new provider mid-suite.
## Usage: Basic Example
Production code calls the instruments automatically. Consumers add a meter / tracer provider at startup:
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
metrics.setGlobalMeterProvider(new MeterProvider({/* …readers… */}));
```
## Usage: Advanced Example
In tests, install an in-memory exporter, drive the system, then assert directly on the captured metrics.
```ts
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { resetInstrumentCache } from "@suaveplan/queue-distribution-http/observability/instruments";
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 100 });
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
resetInstrumentCache();
// … run system under test, then await reader.forceFlush() …
```
## API Reference
- `function resetInstrumentCache(): void`.
- `function recordHttpRequestOutcome(outcome: "success" | "failure"): void`.
- `function recordHttpRequestDuration(durationMs, outcome): void`.
- `function recordConnectAttempt(outcome: "success" | "failure" | "exhausted"): void`.
- `function getSendTaskTracer(): Tracer`, `function getConnectTracer(): Tracer`.
## Implementation Notes
The instruments share a single meter handle so the per-call overhead in the hot path is a single map lookup. `resetInstrumentCache()` is wired exclusively for tests; production code never calls it because the underlying global provider is set once at startup. The `outcome` attribute is constrained to the three documented values for connect attempts and two for requests — keeping the cardinality bounded for backend dashboards.

View File

@@ -0,0 +1,82 @@
# `RemoteWorker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RemoteWorker` extends `AbstractWorker` and delegates execution to a remote endpoint via an injected `WorkerCommunicator`. It manages the connect / reconnect lifecycle (with `@suaveplan/retry`-style exponential backoff routed through `sleep`), runs a periodic health-check loop, and reacts to communication failures by pinging and either reporting the worker still-reachable or marking it `UNHEALTHY`. The connect loop is bounded — no recursion — and every retry attempt is captured in the OTel `queue_dist_http.connect.attempts_total` counter.
## Features
- `start()` calls `#connect` (exponential backoff between attempts) then starts a `setInterval` health-check loop.
- `stop(force, signal?)` halts the loop, disconnects the communicator, and flips `#isConnected` to `false`.
- `doExecuteTask` refuses when not connected (`HTTP.CONNECT_FAILED`).
- Communication-error recovery: ping with a 5-second timeout; reachable → log + propagate; unreachable → `markUnhealthy("Unreachable")` + `#isConnected = false`.
- `getHealth()` proxies to the remote and refreshes `lastHealthCheck` / `lastHealthStatus`; on failure returns a fallback unhealthy payload.
- Configurable `healthCheckIntervalMs`, `maxConnectionAttempts`, `initialBackoffMs`, `maxBackoffMs`.
- `getCapabilities()` advertises `{ isRemote: true, protocol, endpoint }` so strategies can route work.
## Usage: Basic Example
```ts
import { HttpWorkerCommunicator } from "@suaveplan/queue-distribution-http";
import { RemoteWorker } from "@suaveplan/queue-distribution-http/worker/remote/remote";
const communicator = new HttpWorkerCommunicator();
const worker = new RemoteWorker("https://worker.example", communicator, {
healthCheckIntervalMs: 15_000,
maxConnectionAttempts: 5,
});
await worker.start();
const result = await worker.executeTask(task, input);
await worker.stop();
```
## Usage: Advanced Example
For deterministic property-tests, inject a custom communicator and watch connect retries with a fake clock.
```ts
import { RemoteWorker } from "@suaveplan/queue-distribution-http/worker/remote/remote";
import type { WorkerCommunicator } from "@suaveplan/queue-distribution-http";
let connectAttempts = 0;
const flakyCommunicator: WorkerCommunicator = {
sendTask: async (_e, _t, input) => input as never,
connect: async () => {
connectAttempts += 1;
if (connectAttempts < 3) throw new Error("refused");
},
checkStatus: async () => "ONLINE",
checkHealth: async () => ({
isHealthy: true,
metrics: { cpu: 0, memory: 0, tasksFailed: 0, tasksSucceeded: 0, responseTime: 0 },
}),
disconnect: async () => undefined,
ping: async () => 5,
getProtocol: () => "http",
};
const worker = new RemoteWorker("https://worker", flakyCommunicator, {
maxConnectionAttempts: 5,
initialBackoffMs: 0,
healthCheckIntervalMs: 60_000,
});
await worker.start();
console.log(connectAttempts); // 3
```
## API Reference
- `class RemoteWorker extends AbstractWorker`.
- `constructor(endpoint, communicator, options?: RemoteWorkerOptions, deps?: AbstractWorkerDeps)`.
- `interface RemoteWorkerOptions extends AbstractWorkerOptions` — `healthCheckIntervalMs?`, `maxConnectionAttempts?`, `initialBackoffMs?`, `maxBackoffMs?`.
- `getEndpoint(): string`, `getCommunicator(): WorkerCommunicator`, `isConnected(): boolean`.
- `getHealth(signal?): Promise<WorkerHealth>` — overrides the abstract default.
- Protected: `doExecuteTask`, `doStart`, `doStop`, `getType()`, `getCapabilities()`.
## Implementation Notes
The connect loop iterates up to `maxConnectionAttempts` times, doubling the delay each iteration (capped at `maxBackoffMs`). On every retry, the loop also re-checks the remote status — so a worker that responds OK on the third attempt connects without further delay. Communication errors during sendTask are NOT silently swallowed: the worker rethrows so the calling `WorkerManager` can retry against a different worker via its load-balancing strategy. The ping-based reachability probe runs in parallel with the rethrow so the worker's `status` is up-to-date by the time the next strategy invocation observes the pool.

View File

@@ -0,0 +1,68 @@
# @suaveplan/queue-distribution-worker-thread - Web-Worker / Node worker_threads transport plugin for @suaveplan/queue-distribution: caller-injected WorkerFactory bridges to the platform-native worker, message correlation, Zod-validated reply envelope, bounded outstanding-request map.
## Summary
Web-Worker / Node `worker_threads` transport plugin for `@suaveplan/queue-distribution`. Ships `WorkerThreadCommunicator` (a `ThreadWorkerLike`-based message bridge with Zod-validated envelopes, bounded outstanding-request map, AbortSignal cancellation, structured timeouts) and `WorkerThreadWorker` (an `AbstractWorker` subclass that drives the communicator and reacts to crash / exit events).
The plugin lives separately from the core distribution package so HTTP-only and in-process-only consumers don't carry worker-thread bridge code they never use.
<!-- begin:auto-docs -->
## Documentation tree
- [worker-thread](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread-communicator](./src/communicator/worker-thread/worker-thread-communicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queue-distribution-worker-thread-error](./src/errors/queue-distribution-worker-thread-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker-thread](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [worker-thread-worker](./src/worker/worker-thread/worker-thread-worker.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/queue-distribution-worker-thread
```
## Quick start
```ts
import {
WorkerManager,
WorkerPool,
} from "@suaveplan/queue-distribution";
import { WorkerThreadWorker } from "@suaveplan/queue-distribution-worker-thread";
const pool = new WorkerPool();
pool.addWorker(
new WorkerThreadWorker(
() => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }),
{ id: "hot-1", capacity: 4 },
),
);
await pool.startAllWorkers();
const manager = new WorkerManager(pool, { autoHealthCheck: false });
const result = await manager.assignTask(task, input);
```
## Modules
- [Errors](./src/errors/queue-distribution-worker-thread-error.md) — `QueueDistributionWorkerThreadError` + 4 stable `WT.*` codes.
- [WorkerThreadCommunicator](./src/communicator/worker-thread/worker-thread-communicator.md) — message bridge with Zod envelope.
- [WorkerThreadWorker](./src/worker/worker-thread/worker-thread-worker.md) — `AbstractWorker` subclass.
- [Observability](./src/observability/instruments.md) — OTel counters, histograms, tracer.
## Design highlights
- 100% test coverage (statements + branches + functions + lines).
- Same class runs against browser `Worker` and Node `worker_threads` via injectable `WorkerFactory`.
- Bounded `Map<requestId, PendingRequest>` — never leaks outstanding entries.
- Zod-validated envelope in both directions rejects malformed traffic at the boundary.
- Structured `QueueDistributionWorkerThreadError` subclasses with a frozen stable-code registry.
- AbortSignal threaded through every request; terminate() fails all in-flight requests.
## Status
Part of the rebuild — version `0.1.0` while it stabilises.

View File

@@ -0,0 +1,70 @@
# `WorkerThreadCommunicator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkerThreadCommunicator` bridges `@suaveplan/queue-distribution`'s `WorkerCommunicator` shape to a backing thread worker via message passing. The factory is injectable so the same class runs against browser `Worker`, Node `worker_threads`, or any test shim conforming to the structural `ThreadWorkerLike` shape. Every outbound request carries a Zod-validated envelope; every inbound reply is correlated back to its pending request by `id` and validated against a reply schema.
## Features
- Generic over the thread worker — pass any `() => ThreadWorkerLike` factory.
- Bounded `Map<requestId, PendingRequest>` of outstanding requests; never leaks.
- Per-request timeout via `setTimeout`, configurable via `defaultTimeoutMs` or per-call.
- AbortSignal threaded through every request; aborting cleans up the entry and rejects with `WT.WORKER_TERMINATED`.
- Zod schemas at the boundary in both directions; malformed replies surface as `WT.MESSAGE_MALFORMED`.
- `terminate()` synchronously fails every outstanding request, clears the map, and terminates the backing worker.
- Injectable `Clock` and `IdGenerator`.
- OTel counters and histograms per request (`queue_dist_wt.request_total{kind, outcome}`, `queue_dist_wt.request.duration_ms{kind, outcome}`).
## Usage: Basic Example
```ts
import { WorkerThreadCommunicator } from "@suaveplan/queue-distribution-worker-thread/communicator/worker-thread/worker-thread-communicator";
const communicator = new WorkerThreadCommunicator(
() => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }),
{ workerId: "audio-worker" },
);
const result = await communicator.sendTask("", task, input);
```
## Usage: Advanced Example
In tests, drive the communicator from a `ThreadWorkerLike` shim that captures posted messages and lets the test simulate replies.
```ts
import { WorkerThreadCommunicator } from "@suaveplan/queue-distribution-worker-thread/communicator/worker-thread/worker-thread-communicator";
const posted: unknown[] = [];
let onMessage: ((m: unknown) => void) | undefined;
const shim = {
postMessage: (m: unknown) => posted.push(m),
addEventListener: (event, handler) => {
if (event === "message") onMessage = (data) => handler({ data });
},
terminate: () => undefined,
};
const c = new WorkerThreadCommunicator(() => shim, { workerId: "test" });
const promise = c.sendTask("", task, input);
const envelope = posted[0] as { id: string };
onMessage?.({ id: envelope.id, ok: true, data: "OK" });
console.log(await promise); // "OK"
```
## API Reference
- `interface ThreadWorkerLike` — structural worker shape (`postMessage`, `addEventListener`, `terminate`).
- `type WorkerFactory = () => ThreadWorkerLike`.
- `class WorkerThreadCommunicator`.
- `constructor(factory: WorkerFactory, options?: WorkerThreadCommunicatorOptions, deps?: WorkerThreadCommunicatorDeps)`.
- `interface WorkerThreadCommunicatorOptions` — `defaultTimeoutMs?`, `workerId?`.
- `interface WorkerThreadCommunicatorDeps` — `clock?`, `idGenerator?`.
- `sendTask`, `checkStatus`, `checkHealth`, `ping`, `terminate`, `getProtocol`, `getBackingWorker`.
## Implementation Notes
Inbound replies that fail the Zod envelope still get a best-effort surface: if the malformed payload has a string `id` matching a pending request, that request is rejected with `WT.MESSAGE_MALFORMED`. Without an id, the reply is silently dropped — it's unclaimable. The timeout `setTimeout` callback always calls `this.#pending.delete(id)` unconditionally because every other settle path (message arrival, abort, terminate) clears the timer before deleting; if the timer fires, the entry must still be there. Bypassing the entry-existence check lets coverage hit 100% without a defensive race.

View File

@@ -0,0 +1,76 @@
# `QueueDistributionWorkerThreadError` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`QueueDistributionWorkerThreadError` is the base class for every structured error thrown by `@suaveplan/queue-distribution-worker-thread`. Each subclass carries a stable code from the registry produced by `defineCodes("WT", [...])`. Consumers match programmatically on `error.code` to distinguish a worker crash from a malformed reply, a request timeout, or a graceful termination — without parsing error messages.
## Features
- 4 stable codes — `WORKER_CRASHED`, `MESSAGE_MALFORMED`, `REQUEST_TIMEOUT`, `WORKER_TERMINATED`.
- One concrete subclass per code with a typed constructor signature.
- Optional `cause` narrowed to `Error` instances only.
- Compatible with `SuaveplanError` cause-chain traversal and i18n message resolver.
## Usage: Basic Example
```ts
import {
QueueDistributionWorkerThreadError,
WT_CODES,
} from "@suaveplan/queue-distribution-worker-thread/errors/queue-distribution-worker-thread-error";
try {
await worker.executeTask(task, input);
} catch (error: unknown) {
if (
error instanceof QueueDistributionWorkerThreadError &&
error.code === WT_CODES.WORKER_CRASHED
) {
// Re-route to a different worker.
}
throw error;
}
```
## Usage: Advanced Example
Use the discriminated codes to drive recovery strategy per failure class.
```ts
import {
QueueDistributionWorkerThreadError,
WT_CODES,
} from "@suaveplan/queue-distribution-worker-thread/errors/queue-distribution-worker-thread-error";
function recoveryPolicy(error: unknown): "retry" | "drop" | "fatal" {
if (!(error instanceof QueueDistributionWorkerThreadError)) {
return "fatal";
}
switch (error.code) {
case WT_CODES.REQUEST_TIMEOUT:
return "retry";
case WT_CODES.WORKER_TERMINATED:
return "drop";
case WT_CODES.WORKER_CRASHED:
case WT_CODES.MESSAGE_MALFORMED:
return "fatal";
}
}
```
## API Reference
- `const WT_CODES` — frozen registry of `WT.*` codes.
- `type QueueDistributionWorkerThreadErrorCode` — union of every code.
- `class QueueDistributionWorkerThreadError extends SuaveplanError<QueueDistributionWorkerThreadErrorCode>`.
- `class WorkerThreadCrashedError({ workerId, cause? })`.
- `class WorkerThreadMessageMalformedError({ workerId, cause? })`.
- `class WorkerThreadRequestTimeoutError({ workerId, requestId, timeoutMs })`.
- `class WorkerThreadTerminatedError({ workerId, requestId })`.
## Implementation Notes
`MESSAGE_MALFORMED` is used both for replies that fail the Zod envelope and for `ok: false` replies — both represent the worker telling us "this isn't going to work" without specifying _what_ about the worker is broken. If the upstream needs different classifications, the inbound payload schema is the place to extend, not the error registry. The `WORKER_CRASHED` and `WORKER_TERMINATED` codes are aimed at supervision: the first is what the operator wants to alert on; the second is what the manager wants to filter out of "real failures" during a graceful drain.

View File

@@ -0,0 +1,61 @@
# Worker-thread plugin OTel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`observability/instruments.ts` centralises every OpenTelemetry counter, histogram, and tracer emitted by `@suaveplan/queue-distribution-worker-thread`. Instruments register under the meter / tracer name `"@suaveplan/queue-distribution-worker-thread"` so SDK consumers can route them with a single rule. Tracer / meter handles are cached behind `resetInstrumentCache()` for tests.
## Features
- Counters: `queue_dist_wt.request_total{kind, outcome}`, `queue_dist_wt.worker.crashed_total`, `queue_dist_wt.worker.terminated_total`.
- Histogram: `queue_dist_wt.request.duration_ms{kind, outcome}`.
- Tracer: `queue_dist_wt.sendTask`.
- `WorkerThreadKind` union (`"task" | "status" | "health" | "ping"`) keeps the cardinality of the `kind` dimension bounded.
- `resetInstrumentCache()` for tests that install a new provider mid-suite.
## Usage: Basic Example
Production code emits records automatically. Consumers wire a meter / tracer provider at startup:
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
metrics.setGlobalMeterProvider(new MeterProvider({/* …readers… */}));
```
## Usage: Advanced Example
In tests, install an in-memory exporter, drive the system, then assert directly on the captured metrics.
```ts
import { resetInstrumentCache } from "@suaveplan/queue-distribution-worker-thread/observability/instruments";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 100 });
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
resetInstrumentCache();
// … drive the system, then await reader.forceFlush() …
```
## API Reference
- `function resetInstrumentCache(): void`.
- `function recordRequestOutcome(kind, outcome): void`.
- `function recordRequestDuration(kind, durationMs, outcome): void`.
- `function recordWorkerCrashed(): void`, `function recordWorkerTerminated(): void`.
- `function getSendTaskTracer(): Tracer`.
- `type WorkerThreadKind = "task" | "status" | "health" | "ping"`.
## Implementation Notes
The instruments share a single cached meter handle so per-call overhead in the hot path is a map lookup. `WorkerThreadKind` is a typed union rather than a free-form string so unknown `kind` values never leak into dashboards — every request type the communicator can send is enumerated, keeping cardinality bounded. `resetInstrumentCache()` exists exclusively for tests; production sets the global provider once at startup and never needs to flush the cache.

View File

@@ -0,0 +1,78 @@
# `WorkerThreadWorker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkerThreadWorker` extends `AbstractWorker` (from `@suaveplan/queue-distribution`) and dispatches every task to a backing thread worker via `WorkerThreadCommunicator`. A backing-worker crash event flips the worker to `UNHEALTHY` and increments `WT.WORKER_CRASHED`; `stop(force)` terminates the backing worker and fails every outstanding request with `WT.WORKER_TERMINATED`. The worker reports `{ isRemote: false, isThreaded: true }` capabilities so load-balancing strategies can route work appropriately.
## Features
- Implements the full `Worker` contract via `AbstractWorker` inheritance.
- `doExecuteTask` delegates to `WorkerThreadCommunicator.sendTask`.
- Backing-worker `error` event → `markUnhealthy("Worker crashed")` + OTel `queue_dist_wt.worker.crashed_total++`.
- Backing-worker `exit` event → `markUnhealthy("Worker exited")`.
- `doStop(force)` calls `communicator.terminate()` which fails every outstanding request and tears down the backing worker; OTel `queue_dist_wt.worker.terminated_total++`.
- `getCapabilities()` advertises `{ isRemote: false, isThreaded: true, protocol: "worker-thread" }`.
## Usage: Basic Example
```ts
import {
WorkerManager,
WorkerPool,
} from "@suaveplan/queue-distribution";
import { WorkerThreadWorker } from "@suaveplan/queue-distribution-worker-thread";
const pool = new WorkerPool();
pool.addWorker(
new WorkerThreadWorker(
() => new Worker(new URL("./hot-path.ts", import.meta.url), { type: "module" }),
{ id: "hot-1", capacity: 4 },
),
);
await pool.startAllWorkers();
const manager = new WorkerManager(pool, { autoHealthCheck: false });
const result = await manager.assignTask(task, input);
```
## Usage: Advanced Example
For a Node-side worker pool with `worker_threads`, wrap the platform `Worker` in a thin adapter that matches `ThreadWorkerLike`.
```ts
import { Worker as NodeWorker } from "node:worker_threads";
import { WorkerThreadWorker } from "@suaveplan/queue-distribution-worker-thread";
function nodeWorkerFactory(): ReturnType<typeof Object.assign> {
const node = new NodeWorker("./worker.js");
return {
postMessage: (m) => node.postMessage(m),
addEventListener: (event, handler) => {
if (event === "message") node.on("message", (data) => handler({ data }));
if (event === "error") node.on("error", (error) => handler({ error }));
if (event === "exit") node.on("exit", (code) => handler({ code }));
},
terminate: () => node.terminate(),
};
}
const worker = new WorkerThreadWorker(nodeWorkerFactory, {
id: "node-1",
defaultTimeoutMs: 5_000,
});
```
## API Reference
- `class WorkerThreadWorker extends AbstractWorker`.
- `constructor(factory: WorkerFactory, options?: WorkerThreadWorkerOptions, deps?: AbstractWorkerDeps)`.
- `interface WorkerThreadWorkerOptions extends AbstractWorkerOptions` — `defaultTimeoutMs?`.
- `getCommunicator(): WorkerThreadCommunicator`.
- Protected: `doExecuteTask`, `doStop`, `getType()`, `getCapabilities()`.
## Implementation Notes
The crash / exit handlers fire from inside the structural worker's event-listener registration; both flip status to `UNHEALTHY` and emit observability records but do NOT throw — the worker is no longer usable but the manager's supervisor (running independently in `@suaveplan/queue-distribution`) is the right place to escalate the failure. `defaultTimeoutMs` is forwarded to the communicator only when explicitly set, so the communicator's own default (30 seconds) applies when neither layer is configured.

View File

@@ -0,0 +1,129 @@
# @suaveplan/queue-distribution - Worker-pool distribution layer: load-balancing strategies (round-robin / least-busy / weighted), worker pool, manager with retry + health checks, in-process LocalWorker. Transport-agnostic — HTTP and worker-thread plugins ship as separate packages.
## Summary
Worker-pool distribution layer: load-balancing strategies, worker pool management, manager with retry and health checks, in-process `LocalWorker`. Transport-agnostic — HTTP and worker-thread transports ship as separate plugin packages (`@suaveplan/queue-distribution-http`, `@suaveplan/queue-distribution-worker-thread`).
The package is decoupled from `@suaveplan/queue`'s `Job` model: distribution operates on a minimal `DistributedTask<I, O>` contract so it can serve any consumer that produces typed work units (queue, batch, RPC, agent dispatch).
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/queue-distribution-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue-distribution-error](./src/errors/queue-distribution-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [health-checker](./src/health-checker/health-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [health-checker](./src/health-checker/health-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [internal](./src/internal/noop-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [noop-logger](./src/internal/noop-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [base](./src/load-balancing/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [base](./src/load-balancing/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [least-busy](./src/load-balancing/least-busy/least-busy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [least-busy](./src/load-balancing/least-busy/least-busy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [round-robin](./src/load-balancing/round-robin/round-robin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [round-robin](./src/load-balancing/round-robin/round-robin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [weighted](./src/load-balancing/weighted/weighted.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [weighted](./src/load-balancing/weighted/weighted.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [manager](./src/manager/manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [manager-dispatch](./src/manager/manager-dispatch.md) — `dispatchWithRetry` is the core retry loop of `WorkerManager`. It picks a worker via the configured `LoadBalancingStrat…
- [manager-supervisor](./src/manager/manager-supervisor.md) — The supervisor module owns the worker-health side of `WorkerManager`: periodic health checks across the pool, restart o…
- [manager](./src/manager/manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [task](./src/task/task.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [task](./src/task/task.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worker](./src/worker/worker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- abstract-metrics — `abstract-metrics.ts` carries the…
- [abstract](./src/worker/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [abstract-metrics](./src/worker/abstract/abstract-metrics.md) — `abstract-metrics.ts` carries the pure functions and the small accumulator used by `AbstractWorker` to track success/fa…
- [abstract](./src/worker/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local](./src/worker/local/local.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local](./src/worker/local/local.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [worker](./src/worker/worker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- abstract-metrics — `abstract-metrics.ts` carries the…
<!-- end:auto-docs --> - [queue-distribution-error](./src/errors/queue-distribution-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-checker](./src/health-checker/health-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-checker](./src/health-checker/health-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [internal](./src/internal/noop-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-logger](./src/internal/noop-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/load-balancing/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/load-balancing/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [least-busy](./src/load-balancing/least-busy/least-busy.md) — <!-- begin:auto-docs --> _(no submodules)_- [least-busy](./src/load-balancing/least-busy/least-busy.md) — <!-- begin:auto-docs --> _(no submodules)_- [round-robin](./src/load-balancing/round-robin/round-robin.md) — <!-- begin:auto-docs --> _(no submodules)_- [round-robin](./src/load-balancing/round-robin/round-robin.md) — <!-- begin:auto-docs --> _(no submodules)_- [weighted](./src/load-balancing/weighted/weighted.md) — <!-- begin:auto-docs --> _(no submodules)_- [weighted](./src/load-balancing/weighted/weighted.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager-dispatch](./src/manager/manager-dispatch.md) — `dispatchWithRetry` is the core retry loop of `WorkerManager`. It picks a worker via the configured `LoadBalancingStrat…
- [manager-supervisor](./src/manager/manager-supervisor.md) — The supervisor module owns the worker-health side of `WorkerManager`: periodic health checks across the pool, restart o…
- [manager](./src/manager/manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [task](./src/task/task.md) — <!-- begin:auto-docs --> _(no submodules)_- [task](./src/task/task.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [abstract](./src/worker/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_- [abstract-metrics](./src/worker/abstract/abstract-metrics.md) — `abstract-metrics.ts` carries the pure functions and the small accumulator used by `AbstractWorker` to track success/fa…
- [abstract](./src/worker/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_- [local](./src/worker/local/local.md) — <!-- begin:auto-docs --> _(no submodules)_- [local](./src/worker/local/local.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/worker.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue-distribution-error](./src/errors/queue-distribution-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-checker](./src/health-checker/health-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-checker](./src/health-checker/health-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [internal](./src/internal/noop-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-logger](./src/internal/noop-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/load-balancing/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/load-balancing/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [least-busy](./src/load-balancing/least-busy/least-busy.md) — <!-- begin:auto-docs --> _(no submodules)_- [least-busy](./src/load-balancing/least-busy/least-busy.md) — <!-- begin:auto-docs --> _(no submodules)_- [round-robin](./src/load-balancing/round-robin/round-robin.md) — <!-- begin:auto-docs --> _(no submodules)_- [round-robin](./src/load-balancing/round-robin/round-robin.md) — <!-- begin:auto-docs --> _(no submodules)_- [weighted](./src/load-balancing/weighted/weighted.md) — <!-- begin:auto-docs --> _(no submodules)_- [weighted](./src/load-balancing/weighted/weighted.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager-dispatch](./src/manager/manager-dispatch.md) — `dispatchWithRetry` is the core retry loop of `WorkerManager`. It picks a worker via the configured `LoadBalancingStrat…
- [manager-supervisor](./src/manager/manager-supervisor.md) — The supervisor module owns the worker-health side of `WorkerManager`: periodic health checks across the pool, restart o…
- [manager](./src/manager/manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [task](./src/task/task.md) — <!-- begin:auto-docs --> _(no submodules)_- [task](./src/task/task.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/worker.md) — <!-- begin:auto-docs --> _(no submodules)_- abstract-metrics — `abstract…
- [abstract](./src/worker/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_- [abstract-metrics](./src/worker/abstract/abstract-metrics.md) — `abstract-metrics.ts` carries the pure functions and the small accumulator used by `AbstractWorker` to track success/fa…
- [abstract](./src/worker/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_- [local](./src/worker/local/local.md) — <!-- begin:auto-docs --> _(no submodules)_- [local](./src/worker/local/local.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/worker.md) — <!-- begin:auto-docs --> _(no submodules)_- abstract-metrics — `abstract…
- [queue-distribution-error](./src/errors/queue-distribution-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-checker](./src/health-checker/health-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-checker](./src/health-checker/health-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [internal](./src/internal/noop-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-logger](./src/internal/noop-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/load-balancing/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/load-balancing/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [least-busy](./src/load-balancing/least-busy/least-busy.md) — <!-- begin:auto-docs --> _(no submodules)_- [least-busy](./src/load-balancing/least-busy/least-busy.md) — <!-- begin:auto-docs --> _(no submodules)_- [round-robin](./src/load-balancing/round-robin/round-robin.md) — <!-- begin:auto-docs --> _(no submodules)_- [round-robin](./src/load-balancing/round-robin/round-robin.md) — <!-- begin:auto-docs --> _(no submodules)_- [weighted](./src/load-balancing/weighted/weighted.md) — <!-- begin:auto-docs --> _(no submodules)_- [weighted](./src/load-balancing/weighted/weighted.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager-dispatch](./src/manager/manager-dispatch.md) — `dispatchWithRetry` is the core retry loop of `WorkerManager`. It picks a worker via the configured `LoadBalancingStrat…
- [manager-supervisor](./src/manager/manager-supervisor.md) — The supervisor module owns the worker-health side of `WorkerManager`: periodic health checks across the pool, restart o…
- [manager](./src/manager/manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [task](./src/task/task.md) — <!-- begin:auto-docs --> _(no submodules)_- [task](./src/task/task.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/worker.md) — <!-- begin:auto-docs --> _(no submodules)_- abstract-metrics — `abstract-metrics.ts` carries th…
- [abstract](./src/worker/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_- [abstract-metrics](./src/worker/abstract/abstract-metrics.md) — `abstract-metrics.ts` carries the pure functions and the small accumulator used by `AbstractWorker` to track success/fa…
- [abstract](./src/worker/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_- [local](./src/worker/local/local.md) — <!-- begin:auto-docs --> _(no submodules)_- [local](./src/worker/local/local.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/worker.md) — <!-- begin:auto-docs --> _(no submodules)_- abstract-metrics — `abstract-metrics.ts` carries th…
- [queue-distribution-error](./src/errors/queue-distribution-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-checker](./src/health-checker/health-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [health-checker](./src/health-checker/health-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [internal](./src/internal/noop-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ - [noop-logger](./src/internal/noop-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [base](./src/load-balancing/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_ - [base](./src/load-balancing/base/base.md) — <!-- begin:auto-docs --> _(no submodules)_- [least-busy](./src/load-balancing/least-busy/least-busy.md) — <!-- begin:auto-docs --> _(no submodules)_ - [least-busy](./src/load-balancing/least-busy/least-busy.md) — <!-- begin:auto-docs --> _(no submodules)_- [round-robin](./src/load-balancing/round-robin/round-robin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [round-robin](./src/load-balancing/round-robin/round-robin.md) — <!-- begin:auto-docs --> _(no submodules)_- [weighted](./src/load-balancing/weighted/weighted.md) — <!-- begin:auto-docs --> _(no submodules)_ - [weighted](./src/load-balancing/weighted/weighted.md) — <!-- begin:auto-docs --> _(no submodules)_- [manager](./src/manager/manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [manager-dispatch](./src/manager/manager-dispatch.md) — `dispatchWithRetry` is the core retry loop of `WorkerManager`. It picks a worker via the configured `LoadBalancingStrat…
- [manager-supervisor](./src/manager/manager-supervisor.md) — The supervisor module owns the worker-health side of `WorkerManager`: periodic health checks across the pool, restart o…
- [manager](./src/manager/manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/observability/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [task](./src/task/task.md) — <!-- begin:auto-docs --> _(no submodules)_ - [task](./src/task/task.md) — <!-- begin:auto-docs --> _(no submodules)_- [worker](./src/worker/worker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [abstract](./src/worker/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_ - [abstract-metrics](./src/worker/abstract/abstract-metrics.md) — `abstract-metrics.ts` carries the pure functions and the small accumulator used by `AbstractWorker` to track success/fa…
- [abstract](./src/worker/abstract/abstract.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local](./src/worker/local/local.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local](./src/worker/local/local.md) — <!-- begin:auto-docs --> _(no submodules)_ - [worker](./src/worker/worker.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/queue-distribution
```
## Quick start
```ts
import {
LocalWorker,
WorkerManager,
WorkerPool,
} from "@suaveplan/queue-distribution";
const pool = new WorkerPool();
pool.addWorker(new LocalWorker({}, { id: "a", capacity: 4 }));
pool.addWorker(new LocalWorker({}, { id: "b", capacity: 4 }));
await pool.startAllWorkers();
const manager = new WorkerManager(pool, {
autoHealthCheck: false,
maxTaskRetries: 2,
});
const task = {
id: "double",
execute: async (n: number) => n * 2,
getStatus: () => "PENDING" as const,
setStatus: () => {},
getMetadata: () => ({ createdAt: new Date(), attempts: 0 }),
};
console.log(await manager.assignTask(task, 21)); // 42
```
## Modules
- [Task contract](./src/task/task.md) — `DistributedTask<I, O>`, `TaskStatus`, `TaskMetadata`.
- [Worker contract](./src/worker/worker.md) — `Worker`, `WorkerStatus`, `WorkerHealth`, `WorkerInfo` + Zod schemas.
- [AbstractWorker](./src/worker/abstract/abstract.md) — base class with lifecycle FSM, metrics, OTel emission.
- [`AbstractWorker` metrics helpers](./src/worker/abstract/abstract-metrics.md) — `WorkerMetricsState` accumulator.
- [LocalWorker](./src/worker/local/local.md) — in-process executor.
- [Strategy base](./src/load-balancing/base/base.md) — `LoadBalancingStrategy`, `AbstractLoadBalancingStrategy`.
- [RoundRobinStrategy](./src/load-balancing/round-robin/round-robin.md).
- [LeastBusyStrategy](./src/load-balancing/least-busy/least-busy.md) — optional EWMA performance bias.
- [WeightedStrategy](./src/load-balancing/weighted/weighted.md) — injectable `Rng`.
- [PeriodicHealthChecker](./src/health-checker/health-checker.md) — thresholds + cache + timeout.
- [WorkerPool](./src/pool/pool.md) — CRUD + criteria-based search.
- [WorkerManager](./src/manager/manager.md) — public dispatch surface.
- [WorkerManager dispatch](./src/manager/manager-dispatch.md) — bounded retry loop with @suaveplan/retry.
- [WorkerManager supervisor](./src/manager/manager-supervisor.md) — health-loop and restart logic.
- [Errors](./src/errors/queue-distribution-error.md) — `QueueDistributionError` + 12 stable codes.
- [Observability](./src/observability/instruments.md) — every counter, histogram, span the package emits.
- [Internal: NOOP_LOGGER](./src/internal/noop-logger.md).
## Design highlights
- 100% test coverage (statements + branches + functions + lines) under `provider: "istanbul"`.
- Injectable `Clock`, `IdGenerator`, `Rng`, `LoggerLike` everywhere — no `Math.random` / `Date.now` / `uuid` at module scope.
- `AbortSignal` threaded through every async public method.
- Retry routed through `@suaveplan/retry`; bounded loop, no recursion.
- Zod schemas at every public boundary (`WorkerHealthSchema`, `WorkerInfoSchema`, `TaskMetadataSchema`, …).
- Structured `SuaveplanError` subclasses with a frozen stable-code registry.
- Tree-shakeable via 15 per-feature subpath exports.
## Status
This package is part of the rebuild — version `0.1.0` while it stabilises. Graduation to `1.0.0` follows the criteria in [`CLAUDE.md`](../../../CLAUDE.md).

View File

@@ -0,0 +1,84 @@
# `QueueDistributionError` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`QueueDistributionError` is the base class for every structured error thrown by `@suaveplan/queue-distribution`. Each error subclass carries a stable code from the registry produced by `defineCodes("QUEUE_DIST", [...])`, so consumers can match on `error.code` programmatically without string-comparing the message. The registry is exhaustive: every code is thrown by at least one production code path, and consumers can rely on the closed union.
## Features
- 12 stable codes — `WORKER_NOT_AVAILABLE`, `WORKER_DUPLICATE`, `WORKER_BUSY_STOP`, `WORKER_NOT_FOUND`, `WORKER_RESTART_EXHAUSTED`, `NO_AVAILABLE_WORKERS`, `STRATEGY_NO_WORKER_SELECTED`, `TASK_RETRIES_EXHAUSTED`, `HEALTH_CHECK_TIMEOUT`, `HEALTH_CHECK_FAILED`, `INVALID_OPTIONS`, `TASK_CANCELLED`.
- One concrete subclass per code with a typed constructor signature.
- Optional `cause` is narrowed to `Error` instances only (non-Error causes are dropped to avoid pollution of the cause chain).
- Compatible with `SuaveplanError`'s OTel span-event adapter and i18n message resolver.
## Usage: Basic Example
```ts
import {
QueueDistributionError,
QUEUE_DIST_CODES,
TaskRetriesExhaustedError,
} from "@suaveplan/queue-distribution/errors/queue-distribution-error";
try {
await manager.assignTask(task, input);
} catch (error: unknown) {
if (error instanceof QueueDistributionError) {
if (error.code === QUEUE_DIST_CODES.TASK_RETRIES_EXHAUSTED) {
// Route to dead-letter handling.
}
}
throw error;
}
```
## Usage: Advanced Example
Combine the discriminated codes with `@suaveplan/error`'s cause-chain traversal to extract the originating worker error.
```ts
import {
QueueDistributionError,
QUEUE_DIST_CODES,
} from "@suaveplan/queue-distribution/errors/queue-distribution-error";
function rootCauseOf(error: QueueDistributionError): unknown {
let current: unknown = error;
while (current instanceof Error && current.cause !== undefined) {
current = current.cause;
}
return current;
}
if (
error instanceof QueueDistributionError &&
error.code === QUEUE_DIST_CODES.TASK_RETRIES_EXHAUSTED
) {
console.error("root cause:", rootCauseOf(error));
}
```
## API Reference
- `const QUEUE_DIST_CODES` — map produced by `defineCodes("QUEUE_DIST", [...])`.
- `type QueueDistributionErrorCode` — union of every code in the registry.
- `class QueueDistributionError extends SuaveplanError<QueueDistributionErrorCode>`.
- `class WorkerNotAvailableError({ workerId, status, cause? })`.
- `class WorkerDuplicateError({ workerId })`.
- `class WorkerBusyStopError({ workerId, activeTasks })`.
- `class WorkerNotFoundError({ workerId })`.
- `class WorkerRestartExhaustedError({ workerId, attempts })`.
- `class NoAvailableWorkersError()`.
- `class StrategyNoWorkerSelectedError({ strategy })`.
- `class TaskRetriesExhaustedError({ taskId, attempts, cause? })`.
- `class HealthCheckTimeoutError({ workerId, timeoutMs })`.
- `class HealthCheckFailedError({ workerId, cause? })`.
- `class InvalidOptionsError({ field, reason, cause? })`.
- `class TaskCancelledError({ taskId })`.
## Implementation Notes
`asErrorCause` narrows the optional `cause` constructor argument to `Error | undefined`. Non-Error values (strings, plain objects, numbers) are silently dropped because `SuaveplanError`'s cause chain expects an `Error`-shaped chain. This keeps `error.cause.message` reliable for consumers who walk the chain. The registry is built with `as const` so every code is a string-literal subtype of `QueueDistributionErrorCode`, letting TypeScript exhaustiveness-check `switch` statements on `error.code`.

View File

@@ -0,0 +1,75 @@
# `PeriodicHealthChecker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PeriodicHealthChecker` races each worker's `getHealth()` against a configurable timeout, tracks consecutive failure and success counts, flips workers between `ONLINE`/`BUSY` and `UNHEALTHY` only after sustained badness or recovery, and optionally caches recent results so back-to-back probes don't hammer the underlying transport. It implements the `WorkerHealthChecker` interface and is the default health checker that `WorkerManager` constructs when no other is supplied.
## Features
- Promise.race between `worker.getHealth()` and a `setTimeout`-driven timeout (default 5 s).
- `unhealthyThreshold` (default 3) — consecutive failures required to flip to `UNHEALTHY`.
- `healthyThreshold` (default 2) — consecutive successes required to recover.
- Optional caching (`cacheResults`, `cacheTimeMs`; both default-on at 5 s) — backed by the injected `Clock`.
- OTel `queue_dist.health.check` span + `queue_dist.health_check.duration_ms` histogram.
- `getHealthCheckCounts(id)`, `resetHealthCheckCounts(id)`, `clearCache()` for inspection / reset.
- Recovery transitions to `BUSY` when `activeTasks > 0`, else `ONLINE`.
- Injectable `Clock` and `LoggerLike`; defaults are `Date.now` and `NOOP_LOGGER`.
## Usage: Basic Example
```ts
import { PeriodicHealthChecker } from "@suaveplan/queue-distribution/health-checker/health-checker";
const checker = new PeriodicHealthChecker({
unhealthyThreshold: 3,
healthyThreshold: 2,
});
const health = await checker.checkHealth(worker);
if (!health.isHealthy) {
console.warn(`Worker ${worker.id} unhealthy`);
}
```
## Usage: Advanced Example
Combine with a `WorkerPool` to fan-out checks and react to status transitions.
```ts
import { PeriodicHealthChecker } from "@suaveplan/queue-distribution/health-checker/health-checker";
import { WorkerStatus } from "@suaveplan/queue-distribution/worker/worker";
const checker = new PeriodicHealthChecker(
{ unhealthyThreshold: 2, healthyThreshold: 1, cacheResults: false },
{ logger: console as never },
);
async function probeAll(workers: readonly Worker[]): Promise<void> {
const ids = await Promise.all(
workers.map(async (worker) => {
const isHealthy = await checker.isHealthy(worker);
return { id: worker.id, status: worker.status, isHealthy };
}),
);
for (const row of ids) {
if (row.status === WorkerStatus.UNHEALTHY) {
console.error(`Worker ${row.id} is unhealthy`);
}
}
}
```
## API Reference
- `interface WorkerHealthChecker` — `checkHealth(worker, signal?)`, `isHealthy(worker, signal?)`.
- `interface HealthCheckerOptions` — `checkIntervalMs?`, `unhealthyThreshold?`, `healthyThreshold?`, `healthCheckTimeoutMs?`, `cacheResults?`, `cacheTimeMs?`.
- `interface HealthCheckerDeps` — `clock?`, `logger?`.
- `class PeriodicHealthChecker implements WorkerHealthChecker`.
- `getCheckInterval(): number`, `checkHealth(worker, signal?): Promise<WorkerHealth>`, `isHealthy(worker, signal?): Promise<boolean>`.
- `getHealthCheckCounts(workerId): { failures, successes } | null`, `resetHealthCheckCounts(workerId): void`, `clearCache(): void`.
## Implementation Notes
The class flips status both ways without holding a lock; concurrent calls into `checkHealth` against the same worker can theoretically race the threshold check. In practice that means a brief window where status oscillates — acceptable because both `BUSY` and `ONLINE` are eligible for dispatch and the threshold acts as a debouncer over a longer window. The cache is keyed by `worker.id` so removing and re-adding a worker with the same id continues from the old cache; call `clearCache()` after `pool.removeWorker` if isolation matters. The Promise.race uses `setTimeout` rather than the injected `Clock` so the timeout fires under real time even when consumers mock the clock — vitest's `vi.useFakeTimers()` does work because it patches `setTimeout` globally.

View File

@@ -0,0 +1,50 @@
# `NOOP_LOGGER`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NOOP_LOGGER` is a shared `LoggerLike` implementation whose four methods (`debug` / `info` / `warn` / `error`) all return `undefined`. Components inside `@suaveplan/queue-distribution` use it as the default when no logger is injected, so they can call `this.logger.warn(...)` unconditionally without a per-call `?.` check. Sharing one constant across `WorkerPool`, `WorkerManager`, `PeriodicHealthChecker`, `AbstractWorker`, and the supervisor avoids per-file duplication of dead-code stubs that would otherwise show up as uncovered branches in coverage reports.
## Features
- Conforms to the `LoggerLike` structural contract from `@suaveplan/types`.
- All four methods return `undefined` synchronously.
- Lazy by construction: no allocation per call, just an immediate return.
- Reused across every component that accepts an optional `LoggerLike`.
## Usage: Basic Example
```ts
import { NOOP_LOGGER } from "@suaveplan/queue-distribution/internal/noop-logger";
const logger = process.env.DEBUG === "1" ? console : NOOP_LOGGER;
logger.info("starting up");
```
## Usage: Advanced Example
Use the noop logger when wiring a test fixture that doesn't care about log output. Tests that DO want to inspect logs can substitute a simple array-pusher:
```ts
import type { LoggerLike } from "@suaveplan/types";
import { WorkerPool } from "@suaveplan/queue-distribution/pool/pool";
const lines: string[] = [];
const probe: LoggerLike = {
debug: (m) => lines.push(`debug: ${m}`),
info: (m) => lines.push(`info: ${m}`),
warn: () => {},
error: () => {},
};
const pool = new WorkerPool({ logger: probe });
```
## API Reference
- `const NOOP_LOGGER: LoggerLike` — frozen-in-shape no-op implementation.
## Implementation Notes
Centralising the noop methods here means coverage tools see one set of empty-body functions rather than one per component, and a single test covers all of them (`internal/noop-logger.test.ts`). Components import `NOOP_LOGGER` rather than declaring their own local constant to keep this contract honest. The methods are written as `(): void => {}` rather than the shorter `noop` shorthand so TypeScript's structural typing matches the `LoggerLike` signature exactly.

View File

@@ -0,0 +1,74 @@
# Load-balancing strategy base
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LoadBalancingStrategy` is the structural interface every load-balancing strategy implements. `AbstractLoadBalancingStrategy` provides the `filterAvailableWorkers` helper that every concrete strategy uses to narrow the candidate pool to workers that are actually able to accept a task. Splitting the filter into the abstract base means each strategy file stays focused on its scoring policy rather than reimplementing eligibility logic.
## Features
- `selectWorker(workers, task?)` — pick one worker or `null` if none are eligible.
- Optional `notifyWorkerSelected(worker, task)` callback for strategies that maintain per-worker counters.
- Optional `notifyTaskCompleted(worker, task, success, durationMs)` for strategies that learn from outcomes (e.g. EWMA in `LeastBusyStrategy`).
- `getName(): string` — short identifier used in logs and OTel attributes.
- `filterAvailableWorkers(workers)` — keeps `status` in `{ONLINE, BUSY}` AND `activeTasks < capacity` AND `canAcceptTask()` returns `true`.
## Usage: Basic Example
```ts
import {
AbstractLoadBalancingStrategy,
type LoadBalancingStrategy,
} from "@suaveplan/queue-distribution/load-balancing/base/base";
import type { Worker } from "@suaveplan/queue-distribution/worker/worker";
class FirstAvailableStrategy extends AbstractLoadBalancingStrategy {
selectWorker(workers: readonly Worker[]): Worker | null {
return this.filterAvailableWorkers(workers)[0] ?? null;
}
getName(): string {
return "first-available";
}
}
```
## Usage: Advanced Example
Strategies are pluggable — implement the interface directly when you need something more elaborate than what the shipped strategies cover.
```ts
import type { LoadBalancingStrategy } from "@suaveplan/queue-distribution/load-balancing/base/base";
import type { Worker } from "@suaveplan/queue-distribution/worker/worker";
import type { DistributedTask } from "@suaveplan/queue-distribution/task/task";
class AffinityStrategy implements LoadBalancingStrategy {
readonly #lastWorkerByTaskId = new Map<string, string>();
selectWorker(workers: readonly Worker[], task?: DistributedTask): Worker | null {
if (task !== undefined) {
const sticky = this.#lastWorkerByTaskId.get(task.id);
const hit = workers.find((worker) => worker.id === sticky && worker.canAcceptTask());
if (hit !== undefined) {
return hit;
}
}
return workers.find((worker) => worker.canAcceptTask()) ?? null;
}
notifyWorkerSelected(worker: Worker, task: DistributedTask): void {
this.#lastWorkerByTaskId.set(task.id, worker.id);
}
getName(): string {
return "affinity";
}
}
```
## API Reference
- `interface LoadBalancingStrategy` — fields above.
- `abstract class AbstractLoadBalancingStrategy implements LoadBalancingStrategy` — `filterAvailableWorkers(workers)` returns `Worker[]`.
## Implementation Notes
`filterAvailableWorkers` enforces three predicates rather than just one because workers can be `BUSY` but still capacity-available (running another task while having spare slots). The `canAcceptTask()` call is the worker's own override point — `RemoteWorker`, for example, returns `false` while reconnecting even if the pool's view of `status` and `activeTasks` would otherwise permit assignment. Strategies should not bypass this helper.

View File

@@ -0,0 +1,57 @@
# `LeastBusyStrategy`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LeastBusyStrategy` selects the eligible worker with the fewest in-flight tasks. When `usePerformanceMetrics` is enabled it also tracks a rolling-average execution time per worker (success only) and uses it to break ties — preferring faster workers on equal load. The strategy is the right default for heterogeneous pools where worker performance varies but capacity counts are the primary distribution signal.
## Features
- Tie-break order: lower `activeTasks` → (optional) lower `avgExecutionTime` → higher `capacity` → higher `weight`.
- `notifyTaskCompleted(worker, task, success, durationMs)` updates the per-worker EWMA cumulative average; failures are ignored so a flaky worker can't bias selection downward.
- `getWorkerPerformance(id)` returns the stored record or `null`.
- `resetPerformanceMetrics()` clears the registry.
- `setUsePerformanceMetrics(enabled)` toggles the flag at runtime; `isUsingPerformanceMetrics()` reports state.
- `getName()` returns `"least-busy"`.
## Usage: Basic Example
```ts
import { LeastBusyStrategy } from "@suaveplan/queue-distribution/load-balancing/least-busy/least-busy";
const strategy = new LeastBusyStrategy();
// Pool of workers with mixed activeTasks; the one with the fewest wins.
const selected = strategy.selectWorker(pool.getAvailableWorkers());
```
## Usage: Advanced Example
Switch on performance bias when the pool contains a mix of fast and slow nodes (e.g. cross-region workers). The strategy will gradually steer load away from slower nodes.
```ts
import { LeastBusyStrategy } from "@suaveplan/queue-distribution/load-balancing/least-busy/least-busy";
const strategy = new LeastBusyStrategy({ usePerformanceMetrics: true });
// As tasks complete, the worker manager feeds outcomes back.
strategy.notifyTaskCompleted(workerA, task, true, 120);
strategy.notifyTaskCompleted(workerB, task, true, 380);
strategy.selectWorker([workerA, workerB]); // prefers A when activeTasks tied
```
## API Reference
- `class LeastBusyStrategy extends AbstractLoadBalancingStrategy`.
- `constructor(options?: { usePerformanceMetrics?: boolean })`.
- `selectWorker(workers, task?): Worker | null`.
- `notifyTaskCompleted(worker, task, success, durationMs): void`.
- `getWorkerPerformance(workerId): LeastBusyPerformanceRecord | null` — `{ totalExecutionTime, taskCount, avgExecutionTime }`.
- `resetPerformanceMetrics(): void`.
- `setUsePerformanceMetrics(enabled: boolean): void`.
- `isUsingPerformanceMetrics(): boolean`.
- `getName(): string` — returns `"least-busy"`.
## Implementation Notes
Tie-break by `capacity` then `weight` (when no performance data is available) prefers larger, more powerful workers — so a freshly added high-capacity worker absorbs additional load even if every worker currently has zero in-flight tasks. The performance map is per-worker keyed by id and persists across pool changes; removing and re-adding a worker with the same id continues the rolling average. If that behaviour is undesirable, call `resetPerformanceMetrics()` after `pool.removeWorker`.

View File

@@ -0,0 +1,63 @@
# `RoundRobinStrategy`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RoundRobinStrategy` selects available workers in rotation order. The pointer is internal to the instance — two strategies sharing a pool advance independently — and clamps when the pool shrinks below the current index so iteration stays in bounds. Round-robin is the right default for homogeneous worker pools where every worker has similar capacity, weight, and latency characteristics.
## Features
- Cyclic order across eligible workers (filtered via `AbstractLoadBalancingStrategy.filterAvailableWorkers`).
- `reset()` zeroes the pointer for deterministic test setups.
- Pool-shrink-safe: if the pointer index is now beyond the available-worker list, it clamps to zero.
- `getName()` returns `"round-robin"`.
- No external dependencies (no `Clock`, no `Rng`, no `IdGenerator`).
## Usage: Basic Example
```ts
import { RoundRobinStrategy } from "@suaveplan/queue-distribution/load-balancing/round-robin/round-robin";
import { LocalWorker } from "@suaveplan/queue-distribution/worker/local/local";
import { WorkerPool } from "@suaveplan/queue-distribution/pool/pool";
const pool = new WorkerPool();
pool.addWorker(new LocalWorker({}, { id: "a" }));
pool.addWorker(new LocalWorker({}, { id: "b" }));
const strategy = new RoundRobinStrategy();
strategy.selectWorker(pool.getAvailableWorkers()); // worker "a"
strategy.selectWorker(pool.getAvailableWorkers()); // worker "b"
strategy.selectWorker(pool.getAvailableWorkers()); // worker "a"
```
## Usage: Advanced Example
Combine `RoundRobinStrategy` with a `WorkerManager` to drive task dispatch.
```ts
import { WorkerManager } from "@suaveplan/queue-distribution/manager/manager";
import { RoundRobinStrategy } from "@suaveplan/queue-distribution/load-balancing/round-robin/round-robin";
import { WorkerPool } from "@suaveplan/queue-distribution/pool/pool";
const pool = new WorkerPool();
// ... add workers ...
const manager = new WorkerManager(pool, {
balancer: new RoundRobinStrategy(),
autoHealthCheck: false,
maxTaskRetries: 2,
});
await manager.assignTask(task, input);
```
## API Reference
- `class RoundRobinStrategy extends AbstractLoadBalancingStrategy`.
- `selectWorker(workers, task?): Worker | null` — return next eligible worker or `null`.
- `reset(): void` — set pointer to zero.
- `getName(): string` — returns `"round-robin"`.
## Implementation Notes
The strategy stores its pointer with a `#`-prefixed private field so subclasses cannot tamper with it. The clamp-on-shrink behaviour is non-obvious but matters: if a worker is removed from the pool mid-dispatch, the next selection might otherwise reach into a non-existent index. The clamp keeps iteration bounded without needing a synchronisation primitive between pool-shrink events and strategy state.

View File

@@ -0,0 +1,63 @@
# `WeightedStrategy`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WeightedStrategy` selects workers with probability proportional to their `weight`. Randomness is sourced from a caller-supplied `Rng` (from `@suaveplan/types`) — the strategy never calls `Math.random()` at module scope, so tests can replay deterministic sequences and seeded production runs are reproducible. When the pool's total weight is zero or negative, the strategy degrades to uniform random so dispatch can still make forward progress.
## Features
- Proportional probability — a worker with `weight: 6` is selected six times as often as one with `weight: 1`.
- Uniform fallback when `totalWeight <= 0`.
- Injectable `Rng`; deterministic under fixed seeds.
- Zero-allocation hot path (single linear scan through filtered workers).
- `getName()` returns `"weighted"`.
## Usage: Basic Example
```ts
import { WeightedStrategy } from "@suaveplan/queue-distribution/load-balancing/weighted/weighted";
const rng = {
nextUint32: () => Math.floor(Math.random() * 0xffffffff),
nextFloat: () => Math.random(),
};
const strategy = new WeightedStrategy({ rng });
const selected = strategy.selectWorker(pool.getAvailableWorkers());
```
## Usage: Advanced Example
For deterministic tests, drive the strategy from a seeded sequence so each call returns a predictable worker.
```ts
import type { Rng } from "@suaveplan/types";
import { WeightedStrategy } from "@suaveplan/queue-distribution/load-balancing/weighted/weighted";
function fromSequence(values: readonly number[]): Rng {
let i = 0;
return {
nextUint32: () => values[i++ % values.length] * 0xffffffff,
nextFloat: () => values[i++ % values.length],
};
}
// totalWeight = 10, cumulative = [1, 4, 10]
// nextFloat 0.05 -> target 0.5 -> w1; 0.35 -> 3.5 -> w2; 0.95 -> 9.5 -> w3
const strategy = new WeightedStrategy({
rng: fromSequence([0.05, 0.35, 0.95]),
});
```
## API Reference
- `class WeightedStrategy extends AbstractLoadBalancingStrategy`.
- `constructor(deps: { rng: Rng })`.
- `selectWorker(workers, task?): Worker | null`.
- `getName(): string` — returns `"weighted"`.
## Implementation Notes
The weighted selection uses inclusive cumulative bucketing — `target <= cumulative` — so the very-edge case where `target === totalWeight` (only possible if `nextFloat()` returns exactly `1.0`, which is technically allowed by the half-open `[0, 1)` contract for some Rng implementations) still falls inside the last worker's bucket. The trailing `return available[length - 1] as Worker` is a safety net for floating-point precision drift on very long pools where cumulative sums could drift slightly below the target.

View File

@@ -0,0 +1,67 @@
# `dispatchWithRetry`
## Purpose
`dispatchWithRetry` is the core retry loop of `WorkerManager`. It picks a worker via the configured `LoadBalancingStrategy`, executes the task, and retries up to `maxTaskRetries` on failure, sleeping `taskRetryDelayMs` between attempts via `@suaveplan/retry`'s `sleep`. The loop is bounded (no recursive self-call) and honours `AbortSignal` mid-flight — interrupting the sleep, surfacing `TaskCancelledError` thrown from the worker, and short-circuiting cleanly. Implementation is factored out of `manager.ts` so each file stays under the 300-LOC budget.
## Features
- Re-selects a worker on every iteration so a single bad worker can't capture every retry.
- Emits OTel `queue_dist.manager.assign` span with `task_id` and `worker_id` attributes; histogram + counter per attempt.
- Bounded loop — no recursion stack growth on long retry counts.
- `TaskCancelledError` thrown by the worker propagates immediately without further retries.
- Dead-letter via `config.onFailedTask(task)` callback when retries exhaust.
- Per-attempt retry count tracked via `config.trackRetryCount(taskId, attempts)`.
## Usage: Basic Example
`dispatchWithRetry` is internal to `manager.ts`; consumers use `WorkerManager.assignTask`. Direct usage looks like:
```ts
import { dispatchWithRetry } from "@suaveplan/queue-distribution/manager/manager-dispatch";
const result = await dispatchWithRetry(
task,
input,
{
pool,
balancer,
maxTaskRetries: 3,
taskRetryDelayMs: 250,
clock: Date.now,
onFailedTask: (t) => deadLetter.add(t),
trackRetryCount: (id, n) => retries.set(id, n),
},
);
```
## Usage: Advanced Example
The dispatcher consults `pickWorker` and `runOnce` (helpers in this file) to apply the strategy and run a single attempt with full OTel + balancer-callback emission. To extend behaviour without forking, swap the `LoadBalancingStrategy` or wrap the `onFailedTask` callback.
```ts
import { dispatchWithRetry } from "@suaveplan/queue-distribution/manager/manager-dispatch";
await dispatchWithRetry(task, input, {
pool,
balancer: {
selectWorker: (workers) => workers[0] ?? null,
notifyTaskCompleted: (worker, t, success, durationMs) => metrics.record(success, durationMs),
getName: () => "first",
},
maxTaskRetries: 5,
taskRetryDelayMs: 500,
clock: () => Date.now(),
onFailedTask: (task) => alertingClient.fire(task.id),
trackRetryCount: () => undefined,
}, ctrl.signal);
```
## API Reference
- `interface DispatchConfig` — `pool`, `balancer`, `maxTaskRetries`, `taskRetryDelayMs`, `clock`, `onFailedTask`, `trackRetryCount`.
- `function dispatchWithRetry<I, O>(task, input, config, signal?): Promise<O>` — throws `NoAvailableWorkersError`, `StrategyNoWorkerSelectedError`, `TaskCancelledError`, or `TaskRetriesExhaustedError`.
## Implementation Notes
The sleep between retries is routed through `@suaveplan/retry`'s `sleep(ms, signal)` so aborting the supplied signal cleanly interrupts the wait and rethrows as `TaskCancelledError`. The reason the abort handler uses `.catch(() => throw new TaskCancelledError(...))` rather than checking the rejection type is that `sleep` only ever rejects via `AbortSignal` — so any rejection is by definition a cancellation. This keeps the catch arm single-branch and 100%-coverable, and avoids a dead-code arm for non-existent non-abort rejections.

View File

@@ -0,0 +1,64 @@
# Manager supervisor helpers
## Purpose
The supervisor module owns the worker-health side of `WorkerManager`: periodic health checks across the pool, restart of `UNHEALTHY` workers up to a configurable limit, and structured failure handling. It is split into pure functions taking a `SupervisorConfig` so each unit is testable in isolation and so `manager.ts` stays under the 300-LOC budget.
## Features
- `checkAllWorkersHealth(config, signal?)` — fan-out over every worker, returns map of id → `WorkerHealth`, logs a warning when online count falls below `minOnlineWorkers`.
- `handleWorkerFailure(worker, error, config)` — probes health after a failure; flips to `UNHEALTHY` and optionally triggers a restart.
- `restartIfNeeded(worker, config)` — bounded restart attempt with stop+start, post-restart health check, and OTel emission.
- `WORKER_RESTART_EXHAUSTED` emitted on the OTel counter when the limit is reached.
- Per-worker restart counter (`config.restartCounts`) lets the orchestrator share the budget across runs.
## Usage: Basic Example
These helpers are internal to `WorkerManager`. A consumer wanting raw access can compose them directly:
```ts
import { checkAllWorkersHealth } from "@suaveplan/queue-distribution/manager/manager-supervisor";
const results = await checkAllWorkersHealth({
pool,
healthChecker,
autoRestartUnhealthy: true,
maxWorkerRestarts: 3,
minOnlineWorkers: 1,
clock: Date.now,
logger: console as never,
restartCounts: new Map(),
});
for (const [id, health] of results) {
console.log(`${id}: ${health.isHealthy ? "OK" : "DOWN"}`);
}
```
## Usage: Advanced Example
Drive the supervisor manually for a control-plane component that wants to coordinate restarts itself rather than letting `WorkerManager` schedule them.
```ts
import {
handleWorkerFailure,
restartIfNeeded,
} from "@suaveplan/queue-distribution/manager/manager-supervisor";
async function escalate(worker, err): Promise<void> {
await handleWorkerFailure(worker, err, supervisorConfig);
if (worker.status === "UNHEALTHY") {
await restartIfNeeded(worker, supervisorConfig);
}
}
```
## API Reference
- `interface SupervisorConfig` — `pool`, `healthChecker`, `autoRestartUnhealthy`, `maxWorkerRestarts`, `minOnlineWorkers`, `clock`, `logger`, `restartCounts`.
- `async function checkAllWorkersHealth(config, signal?): Promise<Map<string, WorkerHealth>>`.
- `async function handleWorkerFailure(worker, error, config): Promise<void>`.
- `async function restartIfNeeded(worker, config): Promise<void>`.
## Implementation Notes
`restartIfNeeded` swallows pre-restart `stop` errors and post-restart health-check failures by logging rather than throwing — the goal is "best-effort recovery", not "block the supervisor loop on every flake". Counter resetting on successful restart is intentional: a transient failure shouldn't permanently consume the worker's restart budget. The `WORKER_RESTART_EXHAUSTED` log is emitted with the structured error message but not thrown, again so a single saturated worker doesn't poison the whole supervisor scan.

View File

@@ -0,0 +1,91 @@
# `WorkerManager`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkerManager` is the public entry point for distributing tasks across a `WorkerPool`. It owns task assignment (load-balancing, retry, dead-letter), supervision (periodic health checks, auto-restart of unhealthy workers), and option lifecycle (configurable at construction and at runtime via `updateOptions`). Implementation details — the retry-with-backoff loop and the supervisor — live in `manager-dispatch.ts` and `manager-supervisor.ts` so this file stays focused on the surface contract.
## Features
- `assignTask<I, O>(task, input, signal?): Promise<O>` — pick a worker, execute, retry up to `maxTaskRetries`, record dead-letter on exhaustion.
- `assignTasks<I, O>(tasks[], inputs[], signal?)` — fan-out wrapper over `assignTask`.
- Auto health-check loop driven by `setInterval` over `healthCheckIntervalMs`; toggleable at runtime.
- Auto-restart of `UNHEALTHY` workers up to `maxWorkerRestarts`.
- `failedTasks` Set and per-task retry counter for observability.
- Injectable `LoadBalancingStrategy` (default `RoundRobinStrategy`), `WorkerHealthChecker` (default `PeriodicHealthChecker`), `Clock`, `LoggerLike`.
- AbortSignal threaded through every public async method.
- Throws `InvalidOptionsError` for negative numeric options at construction and on `updateOptions`.
## Usage: Basic Example
```ts
import { WorkerManager } from "@suaveplan/queue-distribution/manager/manager";
import { WorkerPool } from "@suaveplan/queue-distribution/pool/pool";
import { LocalWorker } from "@suaveplan/queue-distribution/worker/local/local";
const pool = new WorkerPool();
pool.addWorker(new LocalWorker({}, { id: "a" }));
const manager = new WorkerManager(pool, {
autoHealthCheck: false,
maxTaskRetries: 2,
});
const result = await manager.assignTask(task, input);
```
## Usage: Advanced Example
Stand up a production-shaped manager with a least-busy strategy, performance bias, a custom health checker, and a deterministic clock for tests.
```ts
import { WorkerManager } from "@suaveplan/queue-distribution/manager/manager";
import { WorkerPool } from "@suaveplan/queue-distribution/pool/pool";
import { LeastBusyStrategy } from "@suaveplan/queue-distribution/load-balancing/least-busy/least-busy";
import { PeriodicHealthChecker } from "@suaveplan/queue-distribution/health-checker/health-checker";
const clock = (): number => Date.now();
const pool = new WorkerPool();
// ... add workers ...
const manager = new WorkerManager(
pool,
{
balancer: new LeastBusyStrategy({ usePerformanceMetrics: true }),
healthChecker: new PeriodicHealthChecker({ unhealthyThreshold: 3 }),
autoHealthCheck: true,
healthCheckIntervalMs: 10_000,
maxTaskRetries: 5,
taskRetryDelayMs: 250,
autoRestartUnhealthy: true,
maxWorkerRestarts: 5,
minOnlineWorkers: 2,
},
{ clock, logger: console as never },
);
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 30_000); // hard ceiling
await manager.assignTasks(tasks, inputs, ctrl.signal);
```
## API Reference
- `interface WorkerManagerOptions` — `healthCheckIntervalMs?`, `autoHealthCheck?`, `taskRetryDelayMs?`, `maxTaskRetries?`, `autoRestartUnhealthy?`, `maxWorkerRestarts?`, `minOnlineWorkers?`.
- `interface WorkerManagerDeps` — `clock?`, `logger?`.
- `class WorkerManager`.
- `constructor(pool, options?: { balancer?, healthChecker?, ...WorkerManagerOptions }, deps?: WorkerManagerDeps)`.
- `assignTask<I, O>(task, input, signal?): Promise<O>`.
- `assignTasks<I, O>(tasks[], inputs[], signal?): Promise<O[]>`.
- `startHealthChecks(): void`, `stopHealthChecks(): void`.
- `checkAllWorkersHealth(signal?): Promise<Map<string, WorkerHealth>>`.
- `handleWorkerFailure(worker, error): Promise<void>`.
- `getWorkerPool(): WorkerPool`, `getLoadBalancer(): LoadBalancingStrategy`, `setLoadBalancer(b): void`, `getHealthChecker(): WorkerHealthChecker`.
- `getFailedTasks(): ReadonlySet<DistributedTask>`, `clearFailedTasks(): void`.
- `getTaskRetries(taskId): number`.
- `getOptions(): Required<WorkerManagerOptions>`, `updateOptions(partial): void`.
## Implementation Notes
`updateOptions` performs validate-then-apply: a negative `maxTaskRetries` is rejected before any mutation, so the manager remains in a consistent state across failed updates. Toggling `autoHealthCheck` mid-run actually starts or stops the interval — passing the same value as the current state is a no-op, which lets callers idempotently bounce settings without disrupting an active loop. The dispatch and supervisor configs are recreated on each call to `assignTask` / `checkAllWorkersHealth` so changes via `setLoadBalancer` are picked up immediately without rebuilding the manager.

View File

@@ -0,0 +1,62 @@
# OTel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`observability/instruments.ts` centralises every OpenTelemetry counter, histogram, up-down counter, and tracer the package emits. All instruments are registered under the meter name `"@suaveplan/queue-distribution"` so consumers configuring an SDK can route them with a single rule. Tracer / meter handles are cached behind a `resetInstrumentCache()` escape hatch so tests can swap providers between assertions.
## Features
- Counters: `queue_dist.task.assigned_total`, `queue_dist.task.completed_total{outcome}`, `queue_dist.task.retried_total`, `queue_dist.worker.restart_total{outcome}`, `queue_dist.strategy.no_worker_total`, `queue_dist.worker.execute_total{outcome}`.
- Histograms: `queue_dist.task.duration_ms`, `queue_dist.health_check.duration_ms`, `queue_dist.worker.restart.duration_ms`, `queue_dist.worker.executeTask.duration_ms{outcome}`.
- Up/down counter: `queue_dist.workers.active`.
- Tracers: `queue_dist.manager.assign`, `queue_dist.worker.executeTask`, `queue_dist.health.check`, `queue_dist.worker.restart` (all share the package-level tracer).
- `resetInstrumentCache()` for tests that install a new provider mid-suite.
## Usage: Basic Example
The instruments are called from the production code path automatically. A consumer adding a `MeterProvider` and `TracerProvider` to their app sees them immediately:
```ts
import { metrics, trace } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
metrics.setGlobalMeterProvider(new MeterProvider({/* …readers… */}));
trace.setGlobalTracerProvider(new NodeTracerProvider({/* …exporters… */}));
```
## Usage: Advanced Example
In tests, install an in-memory `MeterProvider`, run the code under test, then assert directly against the recorded metrics. Reset the cache between cases so the new provider is picked up.
```ts
import { resetInstrumentCache } from "@suaveplan/queue-distribution/observability/instruments";
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
AggregationTemporality,
} from "@opentelemetry/sdk-metrics";
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 100 });
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
resetInstrumentCache();
// Run the system under test, then await reader.forceFlush() and inspect exporter._metrics.
```
## API Reference
- `function resetInstrumentCache(): void`.
- Counters: `recordTaskAssigned`, `recordTaskCompleted("success"|"failure")`, `recordTaskRetried`, `recordWorkerRestart("success"|"failure"|"exhausted")`, `recordStrategyNoWorker`, `recordExecuteTaskOutcome("success"|"failure")`.
- Histograms: `recordTaskDuration(ms)`, `recordHealthCheckDuration(ms)`, `recordWorkerRestartDuration(ms)`, `recordExecuteTaskDuration(ms, "success"|"failure")`.
- Up/down counter: `recordWorkerActiveDelta(delta)`.
- Tracers: `getManagerAssignTracer`, `getWorkerExecuteTaskTracer`, `getHealthCheckTracer`, `getWorkerRestartTracer`.
## Implementation Notes
The instruments are created lazily — the first call to `getMeter()` resolves the global meter and caches the handle, so the per-call overhead in the hot path is a single map lookup. All instruments use a single meter (not one per file) because the OTel API resolves names case-sensitively and a typo in a file-local meter name would create a parallel meter that consumers wouldn't see in their dashboards. `resetInstrumentCache()` exists exclusively for tests; production code never needs to call it because the underlying global provider is set once at startup.

Some files were not shown because too many files have changed in this diff Show More