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,19 @@
# `events`
Typed event bus, event sourcing, metrics, and transport bridges.
<!-- begin:auto-docs -->
## Packages in `events`
7 packages.
| Package | Description |
|---|---|
| [`@suaveplan/broadcast-channel`](./broadcast-channel/README.md) | Typed cross-tab messaging built on the Web `BroadcastChannel` API. Browser-tier package — leader election, tab presence and heartbeats, message-type handler re… |
| [`@suaveplan/event-sourcing`](./event-sourcing/README.md) | Event sourcing patterns for Suaveplan: typed aggregate root, in-memory event store with optimistic concurrency, snapshot support, projections, upcasting, and O… |
| [`@suaveplan/events`](./events/README.md) | Typed event-bus substrate for Suaveplan. Pub/sub with deterministic FIFO delivery, interceptor API, bounded-queue backpressure, idempotent listeners, Zod-valid… |
| [`@suaveplan/events-metrics`](./events-metrics/README.md) | OTel metrics companion for `@suaveplan/events`. Attaches via the public interceptor API to record dispatch totals, listener error counts, and latency histogram… |
| [`@suaveplan/events-otel`](./events-otel/README.md) | OpenTelemetry tracing companion for `@suaveplan/events`. Wraps each dispatch in a span, propagates W3C Trace Context, and records listener exceptions as span e… |
| [`@suaveplan/events-schema`](./events-schema/README.md) | Runtime schema-validation companion for `@suaveplan/events`. Validates event payloads at dispatch boundaries via Zod schemas with strict/warn/sample modes and… |
| [`@suaveplan/events-transport`](./events-transport/README.md) | Cross-context event bridges for `@suaveplan/events`: `MessageChannel` (at-least-once with ack/retry) and `BroadcastChannel` (at-most-once, fan-out) transports… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Typed event bus, event sourcing, metrics, and transport bridges.

View File

@@ -0,0 +1,114 @@
# @suaveplan/broadcast-channel - Typed cross-tab messaging built on the BroadcastChannel API — leader election, tab presence, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, StorageLike-backed fallback for Safari < 15.4. Browser-tier.
Typed cross-tab messaging built on the Web `BroadcastChannel` API. Browser-tier package — leader election, tab presence and heartbeats, message-type handler registry, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, deterministic `Clock` / `Rng` / `LoggerLike` injection, and a `StorageLike`-backed fallback transport for Safari < 15.4 (and any sandbox where the native `BroadcastChannel` is unavailable).
<!-- begin:auto-docs -->
## Documentation tree
- [ask](./src/ask/ask.md) — `AskController` is the request-side engine for the cross-tab request/response primitive layered on top of `TypedBroadca…
- [ask](./src/ask/ask.md) — `AskController` is the request-side engine for the cross-tab request/response primitive layered on top of `TypedBroadca…
- [responder](./src/ask/responder.md) — `AskResponderRegistry` is the response-side counterpart to `AskController`. It maps an inbound `handlerKey` to the sing…
- [channel](./src/channel/channel.md) — `TypedBroadcastChannel` is the high-level cross-tab messaging object exported by `@suaveplan/broadcast-channel`. It wra…
- [channel](./src/channel/channel.md) — `TypedBroadcastChannel` is the high-level cross-tab messaging object exported by `@suaveplan/broadcast-channel`. It wra…
- [heartbeat](./src/channel/heartbeat.md) — `HeartbeatRunner` is a tiny lifecycle helper that owns the pair of `setInterval` timers a `TypedBroadcastChannel` needs…
- [errors](./src/errors/codes.md) — This module declares the stable error-code registry for `@suaveplan/broadcast-channel`. Every error the package throws…
- [codes](./src/errors/codes.md) — This module declares the stable error-code registry for `@suaveplan/broadcast-channel`. Every error the package throws…
- [id](./src/id/id.md) — `generateId` is the shared 16-character lowercase-hex identifier generator used for tab IDs and per-message IDs through…
- [id](./src/id/id.md) — `generateId` is the shared 16-character lowercase-hex identifier generator used for tab IDs and per-message IDs through…
- [logger](./src/logger/noop.md) — `noopLogger` is a shared zero-cost `LoggerLike` implementation used as the default by `TypedBroadcastChannel` and by `c…
- [noop](./src/logger/noop.md) — `noopLogger` is a shared zero-cost `LoggerLike` implementation used as the default by `TypedBroadcastChannel` and by `c…
- [otel](./src/otel/metrics.md) — This module declares the six OpenTelemetry counter instruments emitted by `@suaveplan/broadcast-channel`. Every counter…
- [metrics](./src/otel/metrics.md) — This module declares the six OpenTelemetry counter instruments emitted by `@suaveplan/broadcast-channel`. Every counter…
- [schemas](./src/schemas/messages.md) — This module declares the Zod boundary schemas for `@suaveplan/broadcast-channel`. `ChannelMessageSchema` validates ever…
- [messages](./src/schemas/messages.md) — This module declares the Zod boundary schemas for `@suaveplan/broadcast-channel`. `ChannelMessageSchema` validates ever…
- [local-storage](./src/transport/local-storage.md) — `createLocalStorageChannelFactory` is the `StorageLike`-backed fallback transport. Use it whenever the host lacks a nat…
- [native](./src/transport/native.md) — `nativeChannelFactory` is a thin adapter that exposes the global `BroadcastChannel` constructor as a `ChannelLike`. Use…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/broadcast-channel
```
## Quick start
```ts
import {
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory: nativeChannelFactory,
});
channel.open();
channel.on<{ userId: string }>("user:signed-in", (msg) => {
console.log(`peer tab ${msg.senderId} signed in as ${msg.payload.userId}`);
});
channel.send("user:signed-in", { userId: "u_123" });
```
## Fallback transport (Safari < 15.4)
```ts
import {
createLocalStorageChannelFactory,
isNativeBroadcastChannelAvailable,
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
import { yourStorageLike } from "./storage";
const factory = isNativeBroadcastChannelAvailable()
? nativeChannelFactory
: createLocalStorageChannelFactory(yourStorageLike, {
pollIntervalMs: 50,
retentionMs: 30_000,
});
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory,
});
```
## Modules
- [`channel/channel`](./src/channel/channel.md) — `TypedBroadcastChannel` core.
- [`channel/heartbeat`](./src/channel/heartbeat.md) — `HeartbeatRunner` interval pair.
- [`errors/codes`](./src/errors/codes.md) — `BROADCAST_CHANNEL_CODES` registry + four typed error subclasses.
- [`id/id`](./src/id/id.md) — `generateId(rng)` shared 16-hex id generator.
- [`logger/noop`](./src/logger/noop.md) — shared no-op `LoggerLike` default.
- [`otel/metrics`](./src/otel/metrics.md) — six OpenTelemetry counters.
- [`schemas/messages`](./src/schemas/messages.md) — Zod boundary schemas.
- [`transport/native`](./src/transport/native.md) — native `BroadcastChannel` adapter.
- [`transport/local-storage`](./src/transport/local-storage.md) — `StorageLike`-backed fallback.
- [`types/broadcast-channel`](./src/types/broadcast-channel.types.ts) — public shapes.
## Error codes
| Code | When |
|---|---|
| `BROADCAST_CHANNEL.CHANNEL_CLOSED` | `send()` called before `open()` or after `close()` |
| `BROADCAST_CHANNEL.MESSAGE_MALFORMED` | inbound frame fails `ChannelMessageSchema` |
| `BROADCAST_CHANNEL.PAYLOAD_TOO_LARGE` | outbound payload exceeds `config.maxMessageBytes` (default 1 MiB) |
| `BROADCAST_CHANNEL.STORAGE_FAILED` | fallback-transport `StorageLike` get/set/list/delete rejected |
## OpenTelemetry
Six counters under meter `@suaveplan/broadcast-channel@0.1.0`:
- `channel.message.sent` (`{ type, channel }`)
- `channel.message.received` (`{ type, channel }`)
- `channel.message.dropped` (`{ reason, channel }`)
- `channel.tab.joined` (`{ channel }`)
- `channel.tab.left` (`{ channel }`)
- `channel.leader.elected` (`{ channel }`)
## License
MIT

View File

@@ -0,0 +1,130 @@
# `AskController`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AskController` is the request-side engine for the cross-tab request/response
primitive layered on top of `TypedBroadcastChannel`. It owns the registry of
in-flight `ask()` calls, keyed by a freshly drawn `correlationId`, and is
responsible for posting the outbound `ask` wire frame, scheduling the timeout
via the injected `TimersLike`, settling the promise when a matching
`ask-response` frame arrives, and rejecting pending promises during a channel
close. Every behaviour the controller drives — clock reads, ID draws, timer
scheduling, OTel counter emission — flows through injected seams so a single
deterministic test can exercise the entire ask lifecycle in zero wall-clock
time.
## Features
- `correlationId` per call drawn from the injected `Rng`; concurrent asks stay
isolated.
- Injectable `TimersLike` (`setTimer` / `clearTimer`) — production wraps
`setTimeout`, tests pass a fake pair backed by a `FakeClock`.
- Three terminal states for each pending ask: resolve on `outcome: "ok"`,
reject on `outcome: "error"` (handler-thrown error projected as
`{ message, code? }`), reject on timeout (with
`BroadcastChannelAskTimeoutError` or, when `requireResponder: true` and no
peer claimed the key, `BroadcastChannelAskNoResponderError`).
- `cancelAll()` for `channel.close()` integration — clears every timer and
rejects every pending promise with `BroadcastChannelClosedError`.
- OTel counters wired on every hot path: `channel.ask.sent` on issue,
`channel.ask.responded` on settle, `channel.ask.timeout` on deadline miss.
## Usage: Basic
```ts
import { AskController } from "@suaveplan/broadcast-channel/ask/ask";
const controller = new AskController({
clock: () => Date.now(),
rng,
timers: {
setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number,
clearTimer: (h) => clearTimeout(h),
},
channelName: "my-app",
senderId: tabId,
post: (frame) => channel.postMessage(frame),
});
const reply = await controller.issue<string>("ping", { who: "tab-a" });
```
## Usage: Advanced
```ts
// Deterministic timeout test driving a fake TimersLike.
const fired: Array<() => void> = [];
const fakeTimers = {
setTimer: (cb: () => void) => {
fired.push(cb);
return fired.length;
},
clearTimer: (handle: number) => {
fired[handle - 1] = () => {};
},
};
const controller = new AskController({
clock: () => 0,
rng,
timers: fakeTimers,
channelName: "det",
senderId: "asker",
post: () => {},
});
const promise = controller.issue("k", null, {
timeoutMs: 1000,
requireResponder: true,
});
// Drive the timer manually — no real setTimeout was scheduled.
fired[0]?.();
await expect(promise).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.ASK_NO_RESPONDER",
});
```
## API Reference
### `class AskController`
- `constructor(opts: AskControllerOptions)` — wires `clock`, `rng`, `timers`,
`channelName`, `senderId`, and the outbound `post` callback.
- `issue<TResp>(handlerKey, payload, opts?): Promise<TResp>` — synchronously
posts the `ask` frame and registers the pending entry; the promise resolves
when an `ask-response` arrives with a matching `responseTo` and rejects on
timeout or close.
- `settle(frame: AskResponseFrame): void` — resolves or rejects the matching
pending entry; late arrivals (no matching entry) are ignored.
- `noteResponderClaim(key: string): void` — marks every pending ask with the
given `handlerKey` as having seen a peer claim so the `requireResponder`
branch downgrades to a normal timeout.
- `cancelAll(): void` — rejects every pending ask with
`BroadcastChannelClosedError` and clears every timer.
### `buildAskErrorPayload(err: unknown): AskErrorPayload`
Projects an arbitrary thrown value into the wire-safe `{ message, code? }`
shape carried by an `outcome: "error"` `ask-response` frame.
## Implementation Notes
The `correlationId` is drawn from the same injected `Rng` as the existing tab
and message IDs, so seeding the `Rng` in tests gives deterministic
`correlationId` values without any extra wiring. The pending map is keyed by
`correlationId` rather than `handlerKey` so a single asker can have multiple
in-flight asks against the same handler key. Timer handles are stored on each
entry so `cancelAll()` can clear every scheduled callback in O(n) without
walking the underlying timer pool.
Error projection is intentionally lossy — only `message` and `code` cross the
wire — so handlers may throw arbitrary objects locally without leaking
implementation details across tabs. Cross-tab transport is structured-clone,
so passing a real `Error` instance through `payload` would only deliver a
plain object on the receiver anyway; the explicit projection makes that
contract typed and observable rather than implicit.

View File

@@ -0,0 +1,102 @@
# `AskResponderRegistry`
## Purpose
`AskResponderRegistry` is the response-side counterpart to `AskController`. It
maps an inbound `handlerKey` to the single responder function registered for
that key via `TypedBroadcastChannel.onAsk(handlerKey, handler)` and enforces
the package's "at most one responder per key" invariant. The single-responder
rule is deliberate: multi-responder fan-out (`askMany()` / `askAll()`) is its
own future capability with its own design surface, and forcing a second
registration to throw `BroadcastChannelAskHandlerDuplicateError` makes
collisions impossible to ignore at integration time.
## Features
- Single-handler-per-key invariant enforced synchronously on the second
`register()` call.
- Returned disposer is identity-safe: it only removes the slot if the current
occupant is still the handler the caller registered, so an out-of-order
dispose after a re-register does nothing.
- `resolve(key)` returns `undefined` for unregistered keys so the channel can
drop the `ask` frame silently without any error path on the receiver.
- `clear()` drops every responder, invoked once per `channel.close()`.
## Usage: Basic
```ts
import { AskResponderRegistry } from "@suaveplan/broadcast-channel/ask/responder";
const registry = new AskResponderRegistry();
const dispose = registry.register<{ n: number }, { n: number }>(
"increment",
async ({ n }) => ({ n: n + 1 })
);
const handler = registry.resolve("increment");
if (handler !== undefined) {
const out = await handler({ n: 41 }, {
correlationId: "c1",
senderId: "tab-a",
handlerKey: "increment",
});
// out = { n: 42 }
}
dispose();
```
## Usage: Advanced
```ts
// Re-registration after disposal is fine; double-register without disposal
// throws synchronously.
const r = new AskResponderRegistry();
const d1 = r.register("k", () => 1);
expect(() => r.register("k", () => 2)).toThrowError(
/ASK_HANDLER_DUPLICATE/
);
d1();
const d2 = r.register("k", () => 3); // ok
d2();
```
## API Reference
### `class AskResponderRegistry`
- `register<TReq, TResp>(key: string, handler: AskHandler<TReq, TResp>): () => void`
— registers `handler` for `key`; throws
`BroadcastChannelAskHandlerDuplicateError` (code
`BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE`) if `key` is already taken;
returns an identity-safe disposer.
- `resolve(key: string): AskHandler | undefined` — returns the registered
handler or `undefined`. Receiver path drops unregistered `ask` frames.
- `clear(): void` — drop every handler; called from `channel.close()`.
### `type AskHandler<TReq, TResp>`
```ts
type AskHandler<TReq, TResp> = (
payload: TReq,
ctx: AskHandlerContext
) => TResp | Promise<TResp>;
```
The receiver may return a synchronous value or a Promise. The channel awaits
the return value and posts the result as the `ask-response` frame's payload.
## Implementation Notes
The registry deliberately exposes only `register` / `resolve` / `clear`; the
"call the handler and post the response" logic lives on `TypedBroadcastChannel`
because that is where the wire transport, the OTel counters, and the error
projection live. Keeping the registry pure makes it trivial to unit-test in
isolation: every test in `responder.test.ts` constructs a fresh registry, so
state cannot leak between cases.
Identity-safe disposal matters when a handler is re-registered after disposal
in a long-running session — without the identity check, a delayed disposer
from the previous registration would silently drop the new handler and the
asker would see `ASK_TIMEOUT` errors with no obvious cause.

View File

@@ -0,0 +1,122 @@
# channel/channel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TypedBroadcastChannel` is the high-level cross-tab messaging object exported
by `@suaveplan/broadcast-channel`. It wraps the structurally-narrow
`ChannelLike` transport with the policy layer the upstream package made famous:
deterministic lowest-tab-ID leader election, peer-tab presence tracking via
periodic heartbeats with eviction of stale entries, and a per-type handler
registry that returns an `Unsubscribe` closure.
The class is also the boundary at which suaveplan fixes the four enterprise gaps
flagged in `enterprise/broadcast-channel.md`: every inbound frame passes the
Zod `ChannelMessageSchema` before any handler is invoked, every outbound
payload is sized-guarded against `config.maxMessageBytes`, the `ChannelLike`
listener is `(event: MessageEvent) => void` (no `any` smell), and the class
takes injectable `Clock` / `Rng` / `LoggerLike` so all timestamps, IDs, and
diagnostic warnings are deterministic in tests. All hot paths emit OTel
counters under meter `@suaveplan/broadcast-channel`.
## Features
- Typed `send<T>` / `on<T>` / `handleIncoming` surface preserving payload generics.
- Lowest-tab-ID leader election with `leader:elected` lifecycle event on transition.
- Heartbeat + cleanup intervals via the `HeartbeatRunner` helper.
- Zod boundary validation on every inbound frame; malformed frames are dropped, warned, and counted.
- Payload-size guard at `config.maxMessageBytes` raising `BroadcastChannelPayloadTooLargeError`.
- `BroadcastChannelClosedError` on `send` before `open` / after `close`.
- suaveplan-shape `EventBus<BroadcastChannelEventMap>` lifecycle bus.
- Injectable `Clock` / `Rng` / `LoggerLike` from `@suaveplan/types`.
## Usage: Basic
```ts
import {
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory: nativeChannelFactory,
});
channel.open();
channel.on<{ userId: string }>("user:signed-in", (msg) => {
console.log("peer", msg.senderId, "signed in as", msg.payload.userId);
});
channel.send("user:signed-in", { userId: "u_123" });
```
## Usage: Advanced
```ts
import {
createLocalStorageChannelFactory,
isNativeBroadcastChannelAvailable,
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
import type { LoggerLike, StorageLike } from "@suaveplan/types";
declare const memoryStorage: StorageLike;
declare const appLogger: LoggerLike;
const channel = new TypedBroadcastChannel({
config: {
name: "app-bus",
heartbeatInterval: 2_000,
tabTimeout: 8_000,
maxMessageBytes: 64 * 1024,
},
factory: isNativeBroadcastChannelAvailable()
? nativeChannelFactory
: createLocalStorageChannelFactory(memoryStorage, {
pollIntervalMs: 50,
logger: appLogger,
}),
clock: () => Date.now(),
logger: appLogger,
});
channel.events.on("leader:elected", (e) => {
appLogger.info("became leader", { tabId: e.tabId });
});
channel.open();
```
## API Reference
- `class TypedBroadcastChannel`
- `constructor(options: TypedBroadcastChannelOptions)` — accepts `config`, `factory`, optional `clock`, `rng`, `logger`.
- `open(): void` — idempotent; subsequent `open()` is a no-op.
- `close(): void` — safe before `open()`; clears tabs, handlers, and timers.
- `send<T>(type: string, payload: T): void` — throws `BroadcastChannelClosedError` if the channel is not open; throws `BroadcastChannelPayloadTooLargeError` if the JSON-encoded byte length of the message exceeds `config.maxMessageBytes`.
- `on<T>(type: string, handler: ChannelHandler<T>): () => void` — returns an unsubscribe closure.
- `getTabId(): string` — local 16-hex tab ID derived from the injected `Rng`.
- `getIsLeader(): boolean` — true after the local tab has been elected leader.
- `getTabs(): TabInfo[]` — snapshot of known tabs.
- `getTabCount(): number` — count of known tabs (self + peers).
- `isOpen(): boolean`
- `handleIncoming(raw: unknown): void` — exposed for tests; runs the Zod-validated dispatch pipeline.
- `events: EventBus<BroadcastChannelEventMap>` — lifecycle bus.
- `type ChannelHandler<T = unknown>` — `(message: ChannelMessage<T>) => void`.
- `interface TypedBroadcastChannelOptions` — `{ config, factory, clock?, rng?, logger? }`.
## Implementation Notes
`open()` registers a `(event: MessageEvent) => void` listener on the underlying
`ChannelLike` and forwards `event.data` straight into `handleIncoming`. Inbound
validation uses `ChannelMessageSchema.safeParse`; failures call
`recordMessageDropped("malformed", channelName)` and `logger.warn` with the
Zod issues but do not throw. Internal frames (`tab-join`, `tab-leave`,
`heartbeat`) bypass the public counters but still pass validation so a rogue
peer cannot inject coordination state. Leader election is run after every
`tab-join` / `tab-leave` / cleanup tick; only `non-leader -> leader`
transitions emit `leader:elected`. The heartbeat / cleanup pair is owned by
the `HeartbeatRunner` helper to keep this file inside the 300-line budget.

View File

@@ -0,0 +1,92 @@
# channel/heartbeat
## Purpose
`HeartbeatRunner` is a tiny lifecycle helper that owns the pair of
`setInterval` timers a `TypedBroadcastChannel` needs to keep peer presence
warm. The heartbeat callback fires once per `heartbeatInterval` and is meant
to post a `heartbeat` frame plus refresh the local tab's `lastSeen`. The
cleanup callback fires once per `heartbeatInterval * 2` and is meant to walk
the known-tabs map and evict any peer whose `lastSeen` is older than the
configured `tabTimeout`.
The helper exists because the upstream library packed both intervals into
`channel.ts` inline; pulling them out keeps the channel core inside the
300-line hard budget mandated by `AGENTS.md` §3 and gives each timer
lifecycle a discrete unit test surface. The runner has no view into the
channel — it only fires the callbacks it was given, so it composes with any
substitute clock or scheduler the consumer wants to thread.
## Features
- Two independent `setInterval`s driven by a single `heartbeatInterval`.
- Idempotent `stop()` — safe to call before `start()` or twice in a row.
- No shared state with the channel; lifecycle ownership is local to the runner.
## Usage: Basic
```ts
import { HeartbeatRunner } from "@suaveplan/broadcast-channel";
const runner = new HeartbeatRunner({
heartbeatInterval: 1_000,
onHeartbeat: () => console.log("ping"),
onCleanup: () => console.log("gc"),
});
runner.start();
// later
runner.stop();
```
## Usage: Advanced
```ts
import { HeartbeatRunner } from "@suaveplan/broadcast-channel";
class Presence {
private readonly runner = new HeartbeatRunner({
heartbeatInterval: 2_000,
onHeartbeat: () => this.broadcastSelf(),
onCleanup: () => this.evictStale(),
});
public open(): void {
this.runner.start();
}
public close(): void {
this.runner.stop();
}
private broadcastSelf(): void {
// post heartbeat frame ...
}
private evictStale(): void {
// remove peers whose lastSeen is past tabTimeout ...
}
}
```
## API Reference
- `class HeartbeatRunner`
- `constructor(opts: HeartbeatRunnerOptions)`
- `start(): void` — schedules both intervals; calling twice replaces the timers.
- `stop(): void` — clears both intervals if running; safe to call repeatedly.
- `interface HeartbeatRunnerOptions`
- `heartbeatInterval: number` — millisecond cadence for `onHeartbeat`.
- `onHeartbeat: () => void`
- `onCleanup: () => void` — fires at `heartbeatInterval * 2`.
## Implementation Notes
The cleanup cadence is intentionally twice the heartbeat cadence: this gives
peers at least one heartbeat opportunity to refresh `lastSeen` before a
cleanup tick can evict them, even under jitter. The runner stores the two
handles as `ReturnType<typeof setInterval>` so the same code typechecks under
both Bun and Node ambient lib variants. Because `start()` re-assigns the
handles without clearing the prior ones first, callers must call `stop()`
before re-starting — `TypedBroadcastChannel` does exactly that during its
`close()`/`open()` cycle. Tests pin the cadence at 20 ms so the suite stays
fast while still exercising at least one of each callback.

View File

@@ -0,0 +1,99 @@
# errors/codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the stable error-code registry for
`@suaveplan/broadcast-channel`. Every error the package throws surfaces one
of four codes under the `BROADCAST_CHANNEL.*` namespace so downstream
consumers — loggers, problem+json responders, retry wrappers, i18n resolvers
— can map the failure to a retry policy, HTTP status, or translation key
without re-implementing the classifier.
The registry is built via `defineCodes` from `@suaveplan/error`, which yields
a frozen `{ REASON: "BROADCAST_CHANNEL.REASON" }` record at compile time. The
literal-typed union `BroadcastChannelErrorCode` is preserved through the
public surface, so `instanceof BroadcastChannelError` narrows cleanly and
exhaustive `switch (err.code)` blocks stay type-checkable.
## Features
- Four reasons: `CHANNEL_CLOSED`, `MESSAGE_MALFORMED`, `PAYLOAD_TOO_LARGE`, `STORAGE_FAILED`.
- Four typed subclasses extending `SuaveplanError<BroadcastChannelErrorCode>`.
- Every code is thrown from at least one production code path (tested).
- Optional context carriers (`sizeBytes`, `maxBytes`) on the payload-too-large variant.
- `BroadcastChannelError` abstract base for ergonomic `instanceof` checks.
## Usage: Basic
```ts
import {
BROADCAST_CHANNEL_CODES,
BroadcastChannelClosedError,
} from "@suaveplan/broadcast-channel";
try {
channel.send("greet", "hi");
} catch (err) {
if (
err instanceof BroadcastChannelClosedError &&
err.code === BROADCAST_CHANNEL_CODES.CHANNEL_CLOSED
) {
// open the channel first
}
}
```
## Usage: Advanced
```ts
import {
BROADCAST_CHANNEL_CODES,
BroadcastChannelError,
type BroadcastChannelErrorCode,
} from "@suaveplan/broadcast-channel";
function classify(err: unknown): "retry" | "drop" | "alert" {
if (!(err instanceof BroadcastChannelError)) return "alert";
const code: BroadcastChannelErrorCode = err.code;
switch (code) {
case BROADCAST_CHANNEL_CODES.CHANNEL_CLOSED:
return "retry";
case BROADCAST_CHANNEL_CODES.MESSAGE_MALFORMED:
return "drop";
case BROADCAST_CHANNEL_CODES.PAYLOAD_TOO_LARGE:
return "drop";
case BROADCAST_CHANNEL_CODES.STORAGE_FAILED:
return "alert";
default:
return "alert";
}
}
```
## API Reference
- `const BROADCAST_CHANNEL_CODES` — frozen `{ readonly CHANNEL_CLOSED, MESSAGE_MALFORMED, PAYLOAD_TOO_LARGE, STORAGE_FAILED }` mapped to namespaced strings.
- `type BroadcastChannelErrorCode` — union of every code string.
- `abstract class BroadcastChannelError extends SuaveplanError<BroadcastChannelErrorCode>`.
- `class BroadcastChannelClosedError` — `send` on a not-open / closed channel.
- `class BroadcastChannelMessageMalformedError` — Zod validation failure at the boundary.
- `class BroadcastChannelPayloadTooLargeError` — outbound payload over `maxMessageBytes`. Optional `sizeBytes` / `maxBytes` opts populate `context`.
- `class BroadcastChannelStorageFailedError` — fallback transport `StorageLike` rejected.
## Implementation Notes
`defineCodes` returns a `Readonly<Record<Reason, "Namespace.Reason">>` so the
output object can be passed straight into `Object.freeze` consumers and the
keys typecheck as `BroadcastChannelErrorCode`. Each subclass routes through
`pickOpts` to strip `undefined` fields out of `SuaveplanErrorOptions` so the
underlying `SuaveplanError` constructor sees only the keys the caller
explicitly set — this matters for the OTel `toSpanEvent` adapter, which omits
absent attributes rather than emitting `null`. The
`BroadcastChannelPayloadTooLargeError` constructor merges `sizeBytes` /
`maxBytes` into the user-supplied `context` so log lines and span attributes
carry both the offender's size and the configured budget without the caller
having to remember to attach them.

View File

@@ -0,0 +1,79 @@
# id/id
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`generateId` is the shared 16-character lowercase-hex identifier generator
used for tab IDs and per-message IDs throughout
`@suaveplan/broadcast-channel`. The upstream library inlined two near-identical
`generate*Id` helpers — pulling that into a single function with injectable
`Rng` lets the channel core compute deterministic IDs in tests and resolves
the enterprise-doc smell ("Two duplicate `generateTabId` / `generateMsgId`
functions; should share via a local `internal/id.ts`").
The module also exports `defaultRng`, an `Rng` implementation backed by Web
Crypto's `getRandomValues`. Browsers and modern Node / Bun all expose the
global `crypto` object so `defaultRng` works in every supported runtime
without any extra polyfill.
## Features
- Single helper for every ID the package produces.
- Accepts any `Rng` (`@suaveplan/types`) — seeded RNGs make tests deterministic.
- Honours optional `rng.nextBytes` when present (faster, fewer calls).
- Pure-function fallback that derives 8 bytes from two `nextUint32()` calls.
- Stable encoding: lowercase hex, fixed 16 characters, no separators.
## Usage: Basic
```ts
import { defaultRng, generateId } from "@suaveplan/broadcast-channel";
const tabId = generateId(defaultRng);
// tabId === "a3f1c0...", 16 hex chars
```
## Usage: Advanced
```ts
import { generateId } from "@suaveplan/broadcast-channel";
import type { Rng } from "@suaveplan/types";
function seededRng(values: readonly number[]): Rng {
let i = 0;
return {
nextUint32: () => {
const v = values[i % values.length] ?? 0;
i++;
return v >>> 0;
},
nextFloat() {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
const tabId = generateId(seededRng([0xdeadbeef, 0x01020304]));
// tabId === "deadbeef01020304"
```
## API Reference
- `function generateId(rng: Rng): string` — returns a 16-character hex string built from 8 random bytes drawn from `rng`. Uses `rng.nextBytes(8)` when available, otherwise two `nextUint32()` calls.
- `const defaultRng: Rng` — Web-Crypto-backed RNG suitable for production browser code. Exposes `nextUint32`, `nextFloat`, and `nextBytes`.
## Implementation Notes
When `rng.nextBytes` is not supplied, the helper packs two unsigned 32-bit
words into an 8-byte buffer in big-endian order, then encodes each byte as a
two-character zero-padded hex pair. The big-endian packing is deliberate: it
makes the seeded-RNG test contract human-readable (`0xdeadbeef` → first 4
hex bytes `deadbeef`) and keeps the encoding stable if downstream code ever
parses the ID back into bytes. The hex emission is the simplest two-character
output that survives JSON / URL / localStorage transport without escaping. We
avoid `crypto.randomUUID()` because the channel needs a fixed 16-hex shape
for the leader-election sort to be deterministic and because randomUUID
output is not available in every test sandbox.

View File

@@ -0,0 +1,66 @@
# logger/noop
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`noopLogger` is a shared zero-cost `LoggerLike` implementation used as the
default by `TypedBroadcastChannel` and by `createLocalStorageChannelFactory`
when a consumer does not supply their own logger. The four methods accept
the same `(message, context?)` signature as `@suaveplan/logger`'s real
implementation and discard the input. Without this shared instance the two
call sites would each define their own anonymous object and duplicate the
contract.
The module exists because every entry point that takes a `LoggerLike`
ultimately needs a default when the consumer leaves the option blank;
factoring the default into one place removes drift across the package and
keeps the discard-methods covered by exactly one test rather than four.
## Features
- One `LoggerLike` instance reused by every default-logger code path.
- All four methods discard `(message, context)` cleanly — no console writes, no allocations beyond the literal void expressions.
- Importable from both browser and Node contexts (zero dependencies).
## Usage: Basic
```ts
import { noopLogger } from "@suaveplan/broadcast-channel";
import type { LoggerLike } from "@suaveplan/types";
const logger: LoggerLike = noopLogger;
logger.warn("nothing happens", { ctx: 1 });
```
## Usage: Advanced
```ts
import { noopLogger } from "@suaveplan/broadcast-channel";
import type { LoggerLike } from "@suaveplan/types";
function makeChannel(logger?: LoggerLike) {
const effective = logger ?? noopLogger;
effective.debug("channel constructed");
return effective;
}
```
## API Reference
- `const noopLogger: LoggerLike` — exposes `debug`, `info`, `warn`, `error`. Each method accepts an optional `Readonly<Record<string, unknown>>` second argument and returns `void`.
## Implementation Notes
Each method writes `void <param>` to silence the unused-argument lint without
introducing a runtime side effect: TC39's `void` operator evaluates the
operand and returns `undefined`, so the compiler keeps the parameter slot
typed while the runtime cost stays at exactly one nondeterministic load
the V8 / JSC optimiser can fold away. We deliberately do not use empty
arrow bodies (`(...) => {}`) because the parameter name would be flagged
as unused; the explicit `void` form keeps every method symmetric and
documents at the call site that the value is intentionally ignored. There
is no `child(...)` method because `LoggerLike.child` is optional and the
noop logger cannot meaningfully derive sub-bindings.

View File

@@ -0,0 +1,104 @@
# otel/metrics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the six OpenTelemetry counter instruments emitted by
`@suaveplan/broadcast-channel`. Every counter is registered against the meter
`@suaveplan/broadcast-channel@0.1.0` so a single global `MeterProvider`
configuration in the host application picks all of them up. The instruments
close the observability gap the enterprise audit flagged ("No structured
logging / OTel counters"): each lifecycle event a channel observes is
counted, attributed by channel name and either message type or drop reason.
The `refreshBroadcastChannelMeters()` function re-resolves every instrument
against the current global `MeterProvider`. Tests call it after wiring the
in-memory exporter from `tests/setup/polyfills.ts` so subsequent record calls
land on the exporter rather than the noop meter resolved at module load.
## Features
- Six counters: `channel.message.{sent,received,dropped}`, `channel.tab.{joined,left}`, `channel.leader.elected`.
- Stable attribute shape: `{ channel }` plus `{ type }` or `{ reason }`.
- `refreshBroadcastChannelMeters()` for test rebinding.
- No span / tracer surface — counters only, by design.
- Module-load-safe under the noop meter when no `MeterProvider` is configured.
## Usage: Basic
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import {
refreshBroadcastChannelMeters,
TypedBroadcastChannel,
nativeChannelFactory,
} from "@suaveplan/broadcast-channel";
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [] }));
refreshBroadcastChannelMeters();
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory: nativeChannelFactory,
});
channel.open();
channel.send("greet", "hi"); // increments channel.message.sent
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { refreshBroadcastChannelMeters } from "@suaveplan/broadcast-channel";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
refreshBroadcastChannelMeters();
// Later, in a test assertion:
const collected = exporter.getMetrics();
const names = collected.flatMap((rm) =>
rm.scopeMetrics.flatMap((sm) => sm.metrics.map((m) => m.descriptor.name))
);
expect(names).toContain("channel.message.sent");
```
## API Reference
- `refreshBroadcastChannelMeters(): void` — re-resolve every instrument against the current global `MeterProvider`.
- `recordMessageSent(type: string, channelName: string): void`
- `recordMessageReceived(type: string, channelName: string): void`
- `recordMessageDropped(reason: "malformed", channelName: string): void`
- `recordTabJoined(channelName: string): void`
- `recordTabLeft(channelName: string): void`
- `recordLeaderElected(channelName: string): void`
Each `record*` helper calls `.add(1, attrs)` on the corresponding counter.
## Implementation Notes
The instruments are stored in module-local `let` bindings so
`refreshBroadcastChannelMeters` can rebind them without forcing callers to
hold a stable reference. This mirrors the pattern used by
`@suaveplan/slack-client`: at module load the meter is whatever the global
provider returns (typically the noop provider, in which case `.add()` is a
no-op), and the explicit refresh after `setGlobalMeterProvider` swaps in
real instruments. The attribute keys (`channel`, `type`, `reason`) are kept
short to minimise cardinality blow-up; consumer dashboards group on
`channel` first to slice per logical bus. No histograms are exposed — the
package has no meaningful latency axis (`send` is synchronous, dispatch is
in-process) so a histogram would be empty noise.

View File

@@ -0,0 +1,80 @@
# schemas/messages
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the Zod boundary schemas for
`@suaveplan/broadcast-channel`. `ChannelMessageSchema` validates every inbound
frame produced by the underlying `ChannelLike` transport before it is allowed
into the per-type handler registry. `ChannelConfigSchema` validates the
construction-time config object so consumers see a meaningful Zod issue list
instead of an opaque runtime failure when a field is missing or non-numeric.
Boundary validation is the suaveplan-mandated fix for the enterprise gap "No
Zod validation on incoming `ChannelMessage` — a rogue tab could send
garbage." A peer that posts a frame missing `id` or with a negative
`timestamp` no longer reaches user code; the channel drops the frame,
increments `channel.message.dropped`, and warns through the injected
`LoggerLike`.
## Features
- Two schemas: `ChannelMessageSchema`, `ChannelConfigSchema`.
- Default-import Zod (`import z from "zod"`) to satisfy the suaveplan Bun-safe rule.
- `payload` is intentionally `z.unknown()` so callers can layer their own per-type validation downstream.
- Stable required-field set: `{ type, payload, id, senderId, timestamp }`.
- Inferred input types via `z.infer`.
## Usage: Basic
```ts
import { ChannelMessageSchema } from "@suaveplan/broadcast-channel";
const parsed = ChannelMessageSchema.safeParse(incoming);
if (!parsed.success) {
return; // drop
}
```
## Usage: Advanced
```ts
import z from "zod";
import { ChannelMessageSchema } from "@suaveplan/broadcast-channel";
// Layer a per-type payload validator over the boundary schema.
const UserSignedInPayload = z.object({ userId: z.string().min(1) });
const incoming: unknown = receiveOffWire();
const env = ChannelMessageSchema.safeParse(incoming);
if (!env.success) return;
if (env.data.type === "user:signed-in") {
const payload = UserSignedInPayload.safeParse(env.data.payload);
if (!payload.success) return;
// payload.data.userId is typed and validated
}
```
## API Reference
- `const ChannelMessageSchema` — `z.object({ type: z.string().min(1), payload: z.unknown(), id: z.string().min(1), senderId: z.string().min(1), timestamp: z.number().int().nonnegative() })`.
- `const ChannelConfigSchema` — `z.object({ name: z.string().min(1), heartbeatInterval: z.number().int().positive(), tabTimeout: z.number().int().positive(), maxMessageBytes: z.number().int().positive() })`.
- `type ChannelMessageInput = z.infer<typeof ChannelMessageSchema>`.
- `type ChannelConfigInput = z.infer<typeof ChannelConfigSchema>`.
## Implementation Notes
`payload` is left as `z.unknown()` because the channel is type-agnostic on
the wire: per-type validation belongs in the application layer, not in the
transport boundary. Forcing a discriminated union here would push every
caller into either casting or extending the schema, and would force a major
version bump every time an application added a new message type — exactly the
opposite of the loose-coupling story the channel is meant to provide. The
`.min(1)` constraints on `type` / `id` / `senderId` catch the realistic
failure mode (empty strings produced by a malformed encoder) without the
cost of regex matching. `timestamp` uses `.int().nonnegative()` so frames
produced by clocks at zero (a fresh `FakeClock`) still validate while frames
with garbage negative values are rejected.

View File

@@ -0,0 +1,101 @@
# transport/local-storage
## Purpose
`createLocalStorageChannelFactory` is the `StorageLike`-backed fallback
transport. Use it whenever the host lacks a native `BroadcastChannel` (Safari
< 15.4, extension sandboxes, headless environments) so the same
`TypedBroadcastChannel` consumer code keeps working. The factory writes
JSON-encoded frames to the injected storage under a stable prefix and polls
for newly-added keys on a configurable interval; every other channel on the
same `name` and `storage` instance picks the frame up on its next poll.
The fallback fixes the enterprise gap "No `localStorage` fallback for
browsers without `BroadcastChannel` (Safari < 15.4 etc.)". The chosen
contract is `StorageLike` rather than the raw `window.localStorage` API so
the same factory works on top of OPFS, IndexedDB, Garage S3, or any in-memory
test double — consumers wire whichever store their host actually provides.
## Features
- Polled fan-out across all channels on the same `name`.
- Configurable `pollIntervalMs` (default 50 ms) and `retentionMs` (default 30 s).
- Best-effort garbage collection of frames past retention during the same poll loop.
- Optional `LoggerLike` carries `BROADCAST_CHANNEL.STORAGE_FAILED` failures without crashing.
- Injectable `clock` / `idSource` for deterministic tests.
- Per-instance sender-side de-duplication so a channel never replays its own frames.
## Usage: Basic
```ts
import { createLocalStorageChannelFactory } from "@suaveplan/broadcast-channel";
import type { StorageLike } from "@suaveplan/types";
declare const storage: StorageLike;
const factory = createLocalStorageChannelFactory(storage);
const a = factory("app-bus");
a.postMessage({ kind: "hello" });
```
## Usage: Advanced
```ts
import {
createLocalStorageChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
import type { LoggerLike, StorageLike } from "@suaveplan/types";
declare const storage: StorageLike;
declare const appLogger: LoggerLike;
const factory = createLocalStorageChannelFactory(storage, {
pollIntervalMs: 25,
retentionMs: 10_000,
clock: () => Date.now(),
idSource: () => crypto.randomUUID(),
logger: appLogger,
});
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory,
logger: appLogger,
});
channel.open();
```
## API Reference
- `function createLocalStorageChannelFactory(storage: StorageLike, options?: LocalStorageChannelOptions): ChannelFactory`
- `interface LocalStorageChannelOptions`
- `pollIntervalMs?: number` — default `50`.
- `retentionMs?: number` — default `30_000`.
- `clock?: () => number` — default `Date.now`.
- `idSource?: () => string` — default a process-local counter combined with `Date.now().toString(36)`.
- `logger?: LoggerLike` — default no-op; receives `"broadcast-channel.storage.failed"` warnings.
Every returned `ChannelLike`:
- `postMessage(message: unknown): void` — fire-and-forget; failures are logged.
- `close(): void` — stops polling and clears listeners.
- `addEventListener("message", listener: (event: MessageEvent) => void): void`
## Implementation Notes
Frames are stored under
`@suaveplan/broadcast-channel/<urlencoded-name>/<padded-timestamp>/<sequence>`
so the `StorageLike.list({ prefix })` scan returns only the keys for the
target channel, sorted lexicographically by timestamp. The poll loop tracks
seen keys in a per-instance `Set<string>` so a channel never re-delivers a
frame to itself and so writes from the same instance are skipped on the way
back in. Storage rejections inside the polling, write, get, or delete paths
are wrapped in `BroadcastChannelStorageFailedError` and surfaced through the
injected `LoggerLike.warn`; we deliberately do not re-throw because the loop
runs on a timer and an unhandled rejection would crash the host. The garbage
collection step runs inline with the poll: any seen-or-newly-fetched key
whose embedded timestamp is past `retentionMs` is deleted, keeping the
storage footprint bounded under sustained traffic. JSON encoding (rather
than a binary codec) is intentional — the storage backends most likely to
host this transport (`localStorage`, OPFS) already pay an encoding cost, so
binary framing would deliver no real win.

View File

@@ -0,0 +1,78 @@
# transport/native
## Purpose
`nativeChannelFactory` is a thin adapter that exposes the global `BroadcastChannel`
constructor as a `ChannelLike`. Use this in any environment where the
native API is available — every modern browser, Bun, and recent Node (with
the `BroadcastChannel` global). The companion helper
`isNativeBroadcastChannelAvailable()` is a one-line feature detect so callers
can branch between the native transport and the `StorageLike`-backed
fallback at runtime without try/catching around constructor lookups.
The adapter exists to fix the upstream "addEventListener(event: any)" smell:
the native `BroadcastChannel` already hands back `MessageEvent` objects, and
the wrapper preserves that static type in the `ChannelLike` signature. The
surface never lets `any` leak from this code path.
## Features
- Zero-cost wrapper around the native `BroadcastChannel`.
- Statically-typed `(event: MessageEvent) => void` listener forwarding.
- `isNativeBroadcastChannelAvailable()` feature detect.
- Sideeffect-free — `nativeChannelFactory` only constructs `BroadcastChannel` when invoked.
## Usage: Basic
```ts
import {
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory: nativeChannelFactory,
});
channel.open();
```
## Usage: Advanced
```ts
import {
createLocalStorageChannelFactory,
isNativeBroadcastChannelAvailable,
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
import type { StorageLike } from "@suaveplan/types";
declare const storage: StorageLike;
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory: isNativeBroadcastChannelAvailable()
? nativeChannelFactory
: createLocalStorageChannelFactory(storage),
});
```
## API Reference
- `const nativeChannelFactory: ChannelFactory` — `(name) => ChannelLike` wrapping `new BroadcastChannel(name)`.
- `function isNativeBroadcastChannelAvailable(): boolean` — returns `true` if `globalThis.BroadcastChannel` is a function.
## Implementation Notes
The wrapper does not extend `BroadcastChannel` directly because the suaveplan
`ChannelLike` interface narrows `addEventListener` to a single literal type
(`"message"`) — extending the host type would re-introduce the overloaded
union the original surface carried and force callers to handle event types
the channel never delivers (`messageerror`). Forwarding through a small
adapter keeps the public type small and prevents accidental binding of the
`messageerror` event, which the channel's leader-election and presence
machinery is not designed to react to. The feature-detect helper checks for
`typeof globalThis.BroadcastChannel === "function"` rather than `"undefined"
!== typeof BroadcastChannel`: the literal-`function` check sidesteps the
edge case where a host puts a non-callable stub on the global (some
extension sandboxes do this) and forces the caller into the fallback path.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,69 @@
# aggregate-repository
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createAggregateRepository`, the primary entry-point for persisting and loading event-sourced aggregates. It bridges the `Aggregate` base class with an `EventStore`, handling snapshot-based optimisation, optimistic concurrency, and automatic snapshotting when a `snapshotThreshold` is configured.
## Features
- `load(aggregateId)` — loads snapshot (if any), then replays only delta events; throws `AGGREGATE_NOT_FOUND` when no events or snapshot exist
- `save(aggregate)` — drains pending events, computes `expectedVersion`, delegates to `store.append`, then optionally saves a new snapshot
- `exists(aggregateId)` — returns true if any events or a snapshot exist for the ID
- `snapshotThreshold` — automatically snapshots when `aggregate.version % threshold === 0`
- OTel spans on `load` and `save` with aggregate ID and event count attributes
- Injectable `EventStore` for full testability
## Usage: Basic Example
```ts
import { createAggregateRepository, createMemoryEventStore } from "@suaveplan/event-sourcing";
const store = createMemoryEventStore();
const repository = createAggregateRepository({
store,
factory: (id) => new OrderAggregate(id),
});
const order = new OrderAggregate("order-1");
order.place(100);
await repository.save(order);
const loaded = await repository.load("order-1");
console.log(loaded.getVersion()); // 1
```
## Usage: Advanced Example
```ts
import { createAggregateRepository, createMemoryEventStore } from "@suaveplan/event-sourcing";
const repository = createAggregateRepository({
store: createMemoryEventStore(),
factory: (id) => new BankAccount(id),
snapshotThreshold: 50,
});
// After 50 events, a snapshot is auto-saved
// On next load, only events after version 50 are replayed
```
## API Reference
### `createAggregateRepository(options)`
- `options.store: EventStore` — backing store implementation
- `options.factory: (id: string) => T` — creates a blank aggregate given an ID
- `options.snapshotThreshold?: number` — snapshot every N events
### `AggregateRepository<T>`
- `load(aggregateId): Promise<T>` — throws `AGGREGATE_NOT_FOUND` if not found
- `save(aggregate: T): Promise<void>` — no-op if no pending events
- `exists(aggregateId): Promise<boolean>`
## Implementation Notes
The `save` method computes `expectedVersion = aggregate.version - pending.length` before clearing pending events. This is the version the store should be at before applying the new events. The snapshot stores the opaque `state` field using internal access — this works because the `Aggregate` base class uses `protected` (not `private`) visibility.

View File

@@ -0,0 +1,103 @@
# Aggregate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Abstract base class for event-sourced aggregates. Provides the foundational lifecycle for raising domain events, replaying historical event streams, and managing the pending-event queue. Subclasses implement only the `applyEvent` method that folds an individual `DomainEvent` onto the current state — the base class handles version tracking, ID generation, and event accumulation.
## Features
- `raise(type, payload, metadata?)` — emits a new domain event, applies it immediately, increments the version, and queues it for persistence
- `replayFrom(events)` — replays a historical event slice (from snapshot version or zero), updating version and state deterministically
- `clearPendingEvents()` — returns and drains the pending queue; called by the repository after successful persistence
- `getPendingEvents()` — read-only snapshot of the pending queue without clearing
- `getVersion()` — exposes current stream version for the repository
- Injectable `Clock` and `IdGenerator` for deterministic tests
- `getAggregateType()` — overridable aggregate type name used in event envelopes
## Usage: Basic Example
```ts
import { Aggregate } from "@suaveplan/event-sourcing";
import type { DomainEvent } from "@suaveplan/event-sourcing";
interface OrderState { total: number; status: string }
class OrderAggregate extends Aggregate<OrderState> {
constructor(id: string) {
super(id, { total: 0, status: "pending" });
}
place(total: number): void {
this.raise("OrderPlaced", { total });
}
protected applyEvent(event: DomainEvent): void {
if (event.type === "OrderPlaced") {
this.state = { ...this.state, total: (event.payload as { total: number }).total, status: "placed" };
}
}
}
const order = new OrderAggregate("order-1");
order.place(250);
console.log(order.getPendingEvents().length); // 1
```
## Usage: Advanced Example
```ts
import { Aggregate } from "@suaveplan/event-sourcing";
class BankAccount extends Aggregate<{ balance: number }> {
constructor(id: string) {
super(id, { balance: 0 });
}
deposit(amount: number): void {
if (amount <= 0) throw new Error("Amount must be positive");
this.raise("Deposited", { amount });
}
withdraw(amount: number): void {
if (amount > this.state.balance) throw new Error("Insufficient funds");
this.raise("Withdrawn", { amount });
}
protected applyEvent(event: DomainEvent): void {
const p = event.payload as { amount: number };
if (event.type === "Deposited") this.state.balance += p.amount;
if (event.type === "Withdrawn") this.state.balance -= p.amount;
}
get balance(): number { return this.state.balance; }
}
```
## API Reference
### `raise(type, payload, metadata?)`
Emits a domain event. Applies it to state immediately via `applyEvent`, then queues in `pendingEvents`.
### `replayFrom(events)`
Replays a sorted slice of events onto the aggregate without touching `pendingEvents`.
### `clearPendingEvents(): DomainEvent[]`
Returns and clears the pending queue. Called by the repository after persist.
### `getPendingEvents(): readonly DomainEvent[]`
Read-only snapshot; does not clear.
### `getVersion(): number`
Current stream version (0 = no events applied).
## Implementation Notes
The base class uses `protected` (not `private`) fields throughout so that test subclasses and framework-level tooling can inspect or extend state without reflection hacks. The `raise` method always applies the event through `applyEvent` before pushing to the pending queue, ensuring the aggregate state is consistent even before persistence.

View File

@@ -0,0 +1,82 @@
# memory-event-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-memory implementation of the `EventStore` interface for use in tests, prototyping, and single-process deployments. Stores event streams in plain `Map` structures, enforces optimistic concurrency via version checking, supports snapshot storage, provides an async generator stream reader, and optionally publishes committed events onto an `EventBusLike` after successful append.
## Features
- Optimistic concurrency: `append` compares the current stream tail version against `expectedVersion` and throws `EventSourcingError(ES.CONCURRENCY_CONFLICT)` on mismatch
- Stream length cap: `maxStreamLength` option prevents unbounded growth during tests
- Snapshot round-trip: `saveSnapshot` / `loadSnapshot` with per-aggregate keyed storage
- Async generator: `getStream(aggregateId)` yields events one by one for streaming replay
- Bus publish-after-commit: when `bus` is provided, each committed event is published as `domain-event:<aggregateType>:<eventType>`
- OTel instrumentation: spans on `append` and `load`, counters for appended events, concurrency conflicts, and snapshots taken, histogram for append and load duration
## Usage: Basic Example
```ts
import { createMemoryEventStore } from "@suaveplan/event-sourcing";
const store = createMemoryEventStore();
await store.append([{
id: "e1", aggregateId: "a1", aggregateType: "Order",
type: "OrderPlaced", version: 1,
payload: { total: 100 }, metadata: {}, occurredAt: new Date()
}], 0);
const events = await store.load("a1");
console.log(events.length); // 1
```
## Usage: Advanced Example
```ts
import { createMemoryEventStore } from "@suaveplan/event-sourcing";
const bus = {
async publish(topic: string, payload: unknown) {
console.log(`Event published: ${topic}`, payload);
}
};
const store = createMemoryEventStore({ bus, maxStreamLength: 10000 });
// Concurrent append detection
await store.append([event1], 0);
try {
await store.append([event2], 0); // version already at 1
} catch (err) {
// EventSourcingError ES.CONCURRENCY_CONFLICT
}
```
## API Reference
### `createMemoryEventStore(options?)`
Factory returning an `EventStore`. Options: `maxStreamLength`, `clock`, `idGenerator`, `bus`, `logger`.
### `append(events, expectedVersion)`
Atomically appends events to the stream. Throws `CONCURRENCY_CONFLICT` if stream version !== `expectedVersion`.
### `load(aggregateId, fromVersion?)`
Returns all events after `fromVersion` (default 0).
### `loadSnapshot(aggregateId)` / `saveSnapshot(snapshot)`
Stores/retrieves a single snapshot per aggregate.
### `getStream(aggregateId)`
Returns an async generator yielding all events for the aggregate.
## Implementation Notes
The in-memory store is not thread-safe — it is designed for single-threaded Node.js / Bun use. For distributed deployments a database-backed adapter should be used. The `append` operation is synchronous under the hood (no real I/O) but presents an async interface so that all `EventStore` implementations are interchangeable.

View File

@@ -0,0 +1,79 @@
# event-sourcing-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error type and stable error-code registry for `@suaveplan/event-sourcing`. All runtime failures from the event store, aggregate repository, projections, and upcasters surface as `EventSourcingError` instances with a stable machine-readable `code` field, enabling callers to discriminate failures without fragile string matching on error messages.
## Features
- `ES_CODES` registry via `@suaveplan/error`'s `defineCodes` — codes are namespaced `ES.*`
- `EventSourcingError` extends `SuaveplanError` for full cause-chain traversal and wire serialisation
- Six stable codes covering concurrency conflicts, missing aggregates, snapshot corruption, missing upcasters, corrupt streams, and append failures
- `EventSourcingCode` union type for exhaustive switch statements
## Usage: Basic Example
```ts
import { EventSourcingError, ES_CODES } from "@suaveplan/event-sourcing";
throw new EventSourcingError(
ES_CODES.CONCURRENCY_CONFLICT,
"Expected version 5 but stream is at version 6",
{ context: { aggregateId: "order-1", expected: 5, actual: 6 } }
);
```
## Usage: Advanced Example
```ts
import { EventSourcingError, ES_CODES } from "@suaveplan/event-sourcing";
async function saveWithRetry(aggregate: OrderAggregate): Promise<void> {
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
await repository.save(aggregate);
return;
} catch (err) {
if (
err instanceof EventSourcingError &&
err.code === ES_CODES.CONCURRENCY_CONFLICT &&
i < maxRetries - 1
) {
// Reload aggregate and retry
aggregate = await repository.load(aggregate.id);
continue;
}
throw err;
}
}
}
```
## API Reference
### `ES_CODES`
Object mapping error names to fully-qualified code strings:
- `ES_CODES.CONCURRENCY_CONFLICT` — optimistic concurrency version mismatch
- `ES_CODES.AGGREGATE_NOT_FOUND` — no events found for an aggregate ID
- `ES_CODES.SNAPSHOT_INVALID` — snapshot failed schema validation
- `ES_CODES.UPCASTER_MISSING` — no upcaster registered for event type + version pair
- `ES_CODES.STREAM_CORRUPT` — events in a stream have non-sequential versions
- `ES_CODES.APPEND_FAILED` — general append failure (wraps underlying cause)
### `EventSourcingError`
- `constructor(code, message, options?)` — `options.cause` wraps the originating error; `options.context` carries structured metadata
### `EventSourcingCode`
Union type of all valid code strings for exhaustive type checking.
## Implementation Notes
`defineCodes` prefixes all names with `"ES."`, producing strings like `"ES.CONCURRENCY_CONFLICT"`. This namespace avoids collisions with other `@suaveplan/*` error registries.

View File

@@ -0,0 +1,72 @@
# projection-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createProjection`, a factory for building declarative read-model projections over sequences of domain events. Projections are the CQRS query side — they accumulate state from an immutable event log without modifying aggregates. The builder pattern keeps projections pure, composable, and testable independently of the event store.
## Features
- `project(events)` — folds a synchronous array of events onto `initialState` using the registered handlers
- `projectStream(stream)` — async variant that consumes an `AsyncIterable<DomainEvent>` for streaming replay
- Unknown event types are silently ignored — projections are forward-compatible with new event types
- Fully typed: `TState` is inferred from `initialState`
- No side effects — projections always start fresh from `initialState`
## Usage: Basic Example
```ts
import { createProjection } from "@suaveplan/event-sourcing";
const orderSummary = createProjection({
initialState: { total: 0, count: 0 },
handlers: {
OrderPlaced: (state, event) => ({
total: state.total + (event.payload as { amount: number }).amount,
count: state.count + 1,
}),
},
});
const result = orderSummary.project(events);
console.log(result.count); // number of placed orders
```
## Usage: Advanced Example
```ts
import { createProjection, createMemoryEventStore } from "@suaveplan/event-sourcing";
const store = createMemoryEventStore();
// Build projection from async stream
const projection = createProjection({
initialState: [] as string[],
handlers: {
UserRegistered: (state, event) => [
...state,
(event.payload as { username: string }).username,
],
},
});
const result = await projection.projectStream(store.getStream("users"));
```
## API Reference
### `createProjection<TState>(options)`
- `options.initialState: TState` — starting state for each projection run
- `options.handlers: Record<string, (state: TState, event: DomainEvent) => TState>` — reducer per event type
### `Projection<TState>`
- `project(events: DomainEvent[]): TState`
- `projectStream(stream: AsyncIterable<DomainEvent>): Promise<TState>`
## Implementation Notes
Each call to `project` or `projectStream` starts from `initialState` — there is no incremental update. For live projections that need incremental updates, callers should maintain external state and call the handler functions directly, or use a stateful wrapper that holds the accumulated state.

View File

@@ -0,0 +1,66 @@
# event-sourcing.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod validation schemas for all public wire-format types in `@suaveplan/event-sourcing`. These schemas are used at integration boundaries — when deserialising events from persistence layers, validating snapshot data, and parsing upcaster registrations — to ensure structural integrity before domain logic runs.
## Features
- `DomainEventSchema` — validates the full event envelope including version, metadata, and typed occurredAt
- `SnapshotSchema` — validates aggregate snapshots before loading into aggregates
- `EventMetadataSchema` — validates event metadata with passthrough for unknown keys
- `MemoryEventStoreOptionsSchema` — validates store construction options
- `UpcasterOptionsSchema` — validates upcaster registration including the migration function
## Usage: Basic Example
```ts
import { DomainEventSchema } from "@suaveplan/event-sourcing";
const raw = JSON.parse(storedJson);
const event = DomainEventSchema.parse({ ...raw, occurredAt: new Date(raw.occurredAt) });
```
## Usage: Advanced Example
```ts
import { SnapshotSchema } from "@suaveplan/event-sourcing";
import z from "zod";
const MyStateSchema = z.object({ balance: z.number() });
const snapshot = SnapshotSchema.extend({
state: MyStateSchema,
}).parse(rawSnapshot);
// snapshot.state is typed as { balance: number }
```
## API Reference
### `DomainEventSchema`
Validates `{ id, aggregateId, aggregateType, type, version, payload, metadata, occurredAt }`. Version must be a positive integer. `occurredAt` must be a `Date`.
### `SnapshotSchema`
Validates `{ aggregateId, aggregateType, version, state, takenAt }`. Version is non-negative integer.
### `EventMetadataSchema`
Validates optional `causationId`, `correlationId`, `actor` and passes through unknown keys.
### `MemoryEventStoreOptionsSchema`
Optional schema; validates `maxStreamLength` as positive integer if present.
### `UpcasterOptionsSchema`
Validates `{ eventType, fromVersion, toVersion, upcast }`. `fromVersion` must be non-negative; `toVersion` must be positive.
## Implementation Notes
All schemas use `.passthrough()` on metadata to avoid stripping custom keys. The `payload` and `state` fields use `z.unknown()` since the concrete shape depends on the event type — callers may extend the schema for stricter validation.

View File

@@ -0,0 +1,79 @@
# event-sourcing.types
## Purpose
Shared TypeScript type definitions for the `@suaveplan/event-sourcing` package. These types form the contract between aggregates, the event store, projections, and the aggregate repository. Centralising them here prevents circular imports and makes the public API surface explicit.
## Features
- `DomainEvent<TPayload>` — the canonical envelope for all domain events, carrying identity, versioning, and metadata
- `Snapshot` — serialised aggregate state at a given stream version for efficient replay
- `EventStore` — the interface that every store implementation must satisfy
- `AggregateRepository<T>` — the typed repository facade
- `MemoryEventStoreOptions` — injectable clock, ID generator, bus, and logger for the in-memory implementation
- `ProjectionOptions<TState>` / `ProjectionHandlers<TState>` — declarative read-model configuration
- `UpcasterOptions` — event schema migration registration
- `EventMetadata` — free-form cross-cutting metadata with typed optional fields
## Usage: Basic Example
```ts
import type { DomainEvent, Snapshot } from "@suaveplan/event-sourcing";
const event: DomainEvent<{ amount: number }> = {
id: "01HXYZ",
aggregateId: "order-1",
aggregateType: "Order",
type: "OrderPlaced",
version: 1,
payload: { amount: 100 },
metadata: { actor: "user-42" },
occurredAt: new Date(),
};
```
## Usage: Advanced Example
```ts
import type {
DomainEvent,
EventStore,
AggregateRepositoryOptions,
} from "@suaveplan/event-sourcing";
// Custom aggregate repository options with snapshot support
const opts: AggregateRepositoryOptions<MyAggregate> = {
store: myEventStore,
factory: (id) => new MyAggregate(id),
snapshotThreshold: 50,
};
```
## API Reference
### `DomainEvent<TPayload>`
- `id: string` — unique event identifier
- `aggregateId: string` — owning aggregate
- `aggregateType: string` — aggregate class discriminator
- `type: string` — event type discriminator
- `version: number` — 1-based sequence within aggregate stream
- `payload: TPayload` — typed event payload
- `metadata: EventMetadata` — cross-cutting metadata
- `occurredAt: Date` — wall-clock timestamp
### `Snapshot`
Point-in-time capture of aggregate state used to skip full replay.
### `EventStore`
Interface with `append`, `load`, `loadSnapshot`, `saveSnapshot`, `getStream`.
### `MemoryEventStoreOptions`
Injectable options for in-memory store: `clock`, `idGenerator`, `bus`, `logger`, `maxStreamLength`.
## Implementation Notes
The types are purely declarative — no runtime code. Using a separate `types` module keeps the aggregate and store implementations free of cross-dependencies.

View File

@@ -0,0 +1,69 @@
# upcaster
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides event schema migration via a registry-based upcaster pattern. When an aggregate's event payloads evolve over time, old events stored in the event store can be transparently migrated to newer schemas on load. Each upcaster migrates from one payload version to the next, forming a chain that `upcastEvents` walks automatically.
## Features
- `registerUpcaster(options)` — adds a migration function for `eventType` at `fromVersion → toVersion`
- `upcastEvents(events, options?)` — walks the upcaster chain for each event, applying all registered migrations
- `clearUpcasters()` — resets the registry (for test isolation)
- `requireComplete` option — throws `UPCASTER_MISSING` if the chain does not terminate for an event type
- Chain limit of 100 steps prevents infinite loops from misconfigured upcasters
## Usage: Basic Example
```ts
import { registerUpcaster, upcastEvents } from "@suaveplan/event-sourcing";
registerUpcaster({
eventType: "OrderPlaced",
fromVersion: 1,
toVersion: 2,
upcast: (payload) => ({
...(payload as object),
currency: "USD", // added in v2
}),
});
const migrated = upcastEvents(oldEvents);
```
## Usage: Advanced Example
```ts
import { registerUpcaster, upcastEvents, clearUpcasters } from "@suaveplan/event-sourcing";
// Chain: v1 -> v2 -> v3
registerUpcaster({ eventType: "OrderPlaced", fromVersion: 1, toVersion: 2, upcast: addCurrency });
registerUpcaster({ eventType: "OrderPlaced", fromVersion: 2, toVersion: 3, upcast: addTaxId });
const events = await store.load("order-1");
const migrated = upcastEvents(events);
// In tests: clean up
clearUpcasters();
```
## API Reference
### `registerUpcaster(options: UpcasterOptions)`
Registers `options.upcast` to migrate payloads from `options.fromVersion` to `options.toVersion` for `options.eventType`.
### `upcastEvents(events, options?)`
- `options.requireComplete` — if `true`, throws `UPCASTER_MISSING` when chain has gaps
- Returns a new array of events with migrated payloads (original events are not mutated)
### `clearUpcasters()`
Resets the global registry. Use in `afterEach` during tests.
## Implementation Notes
The upcaster registry is module-level (global singleton). This matches the typical pattern for event schema registries. The 100-step chain limit prevents runaway loops while supporting realistic migration depths. The `requireComplete` check is a best-effort safeguard and does not guarantee the upcaster graph has no cycles.

View File

@@ -0,0 +1,32 @@
# @suaveplan/events-metrics - OTel metrics companion for @suaveplan/events: dispatch counters, latency histograms, queue-depth gauges, and active-subscriber counts via interceptor API.
## Summary
OTel metrics companion for `@suaveplan/events`. Attaches via the public interceptor API to record dispatch totals, listener error counts, and latency histograms per event name.
<!-- begin:auto-docs -->
## Documentation tree
- [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_
## Quick Start
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventMetrics } from "@suaveplan/events-metrics";
const bus = new EventBus();
const handle = attachEventMetrics(bus);
await bus.emitAsync("user.created", { id: "u-1" });
console.log(handle.snapshot().dispatchTotal["user.created"]); // 1
handle.detach();
```
## Modules
- [`src/core/attach-event-metrics/attach-event-metrics`](src/core/attach-event-metrics/attach-event-metrics.md) — `attachEventMetrics`, `EventMetricsHandle`, `MetricsSnapshot`
- [`src/metrics/metric-names/metric-names`](src/metrics/metric-names/metric-names.md) — `METRIC_NAMES`, `ATTRIBUTE_KEYS`

View File

@@ -0,0 +1,98 @@
# attach-event-metrics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Attaches OTel metrics instrumentation to an `EventBus` via the public interceptor API from `@suaveplan/events`. It records event dispatch totals, listener error counts, and latency histograms per event name without modifying the bus or its listeners. The attachment is transparent to publishers and subscribers — they do not need to know that metrics are being collected.
The implementation prioritises zero per-dispatch allocation on the hot path for previously seen event names by pre-caching the OTel counter/histogram references and using object property access rather than Map lookups.
## Features
- `attachEventMetrics(bus, options?)` — installs a metrics interceptor and returns a `EventMetricsHandle`
- Dispatch total counter (`suaveplan.events.dispatch.total`) per event name
- Listener error counter (`suaveplan.events.listener.error.total`) per event name
- Dispatch latency histogram (`suaveplan.events.dispatch.latency_ms`) per event name
- `EventMetricsHandle.snapshot()` returns in-process totals without needing an OTel exporter
- `EventMetricsHandle.detach()` removes the interceptor
- Injectable `meter`, `clock`, `logger` for testability
- `eventFilter` option to limit recording to a subset of event names
## Usage: Basic Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventMetrics } from "@suaveplan/events-metrics";
const bus = new EventBus<{ "user.created": { id: string } }>();
const handle = attachEventMetrics(bus);
bus.on("user.created", (payload) => console.log(payload));
await bus.emitAsync("user.created", { id: "u-1" });
const snap = handle.snapshot();
console.log(snap.dispatchTotal["user.created"]); // 1
handle.detach();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventMetrics } from "@suaveplan/events-metrics";
import { metrics } from "@opentelemetry/api";
const bus = new EventBus<{ click: { x: number }; submit: { formId: string } }>();
const handle = attachEventMetrics(bus, {
meter: metrics.getMeter("my-app"),
eventFilter: (name) => name.startsWith("click"),
clock: { now: () => performance.now() },
});
// Only "click" events will be instrumented; "submit" events pass through uninstrumented
await bus.emitAsync("click", { x: 42 });
await bus.emitAsync("submit", { formId: "f-1" });
const snap = handle.snapshot();
console.log(snap.dispatchTotal["click"]); // 1
console.log(snap.dispatchTotal["submit"]); // undefined (filtered out)
handle.detach();
```
## API Reference
### `attachEventMetrics<TMap>(bus, options?): EventMetricsHandle`
Installs a metrics interceptor on `bus`.
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus<TMap>` | The event bus to instrument |
| `options` | `EventMetricsOptions?` | Meter, filter, clock, logger |
**Returns**: `EventMetricsHandle`
### `EventMetricsHandle`
| Method | Returns | Description |
|---|---|---|
| `detach()` | `void` | Remove the interceptor |
| `snapshot()` | `MetricsSnapshot` | In-process metric values |
### `MetricsSnapshot`
| Field | Type | Description |
|---|---|---|
| `dispatchTotal` | `Record<string, number>` | Total dispatches per event name |
| `listenerErrorTotal` | `Record<string, number>` | Total listener errors per event name |
| `avgDispatchLatencyMs` | `Record<string, number>` | Average latency per event name |
| `queueDepth` | `number` | Current queue depth (0 for synchronous bus) |
| `activeSubscribers` | `number` | Active subscriber count |
## Implementation Notes
The metrics interceptor uses `bus.use()` which is the `@suaveplan/events` public interceptor API. It wraps `next()` with a timer, increments counters, and records the histogram. OTel instruments are lazily created on first use to avoid startup cost when the bus is never dispatched. The `queueDepth` and `activeSubscribers` fields in `snapshot()` are reported as 0 because the synchronous `EventBus` does not expose a queue depth API; applications using `BoundedQueue` would need a separate gauge observer.

View File

@@ -0,0 +1,61 @@
# metric-names
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exports the stable constant objects `METRIC_NAMES` and `ATTRIBUTE_KEYS` that define every OTel metric name and span attribute key used across `@suaveplan/events-metrics`. Centralising these names prevents typos, enables IDE completion, and makes it easy to audit the full observability surface of the package.
These constants are the single source of truth consumed by both the instrumentation code and the tests that assert metric names are correct.
## Features
- `METRIC_NAMES` — all five OTel metric names: dispatch total, listener error total, dispatch latency histogram, queue depth gauge, active subscribers gauge
- `ATTRIBUTE_KEYS` — all four OTel attribute keys: event name, event category, listener ID, bus ID
- Both objects use `as const` for full literal-type narrowing
## Usage: Basic Example
```ts
import { METRIC_NAMES, ATTRIBUTE_KEYS } from "@suaveplan/events-metrics";
console.log(METRIC_NAMES.DISPATCH_TOTAL); // "suaveplan.events.dispatch.total"
console.log(ATTRIBUTE_KEYS.EVENT_NAME); // "event.name"
```
## Usage: Advanced Example
```ts
import { METRIC_NAMES, ATTRIBUTE_KEYS } from "@suaveplan/events-metrics";
import { metrics } from "@opentelemetry/api";
const meter = metrics.getMeter("my-app");
const counter = meter.createCounter(METRIC_NAMES.DISPATCH_TOTAL);
counter.add(1, { [ATTRIBUTE_KEYS.EVENT_NAME]: "user.created" });
```
## API Reference
### `METRIC_NAMES`
| Key | Value |
|---|---|
| `DISPATCH_TOTAL` | `"suaveplan.events.dispatch.total"` |
| `LISTENER_ERROR_TOTAL` | `"suaveplan.events.listener.error.total"` |
| `DISPATCH_LATENCY_MS` | `"suaveplan.events.dispatch.latency_ms"` |
| `QUEUE_DEPTH` | `"suaveplan.events.queue.depth"` |
| `ACTIVE_SUBSCRIBERS` | `"suaveplan.events.subscribers.active"` |
### `ATTRIBUTE_KEYS`
| Key | Value |
|---|---|
| `EVENT_NAME` | `"event.name"` |
| `EVENT_CATEGORY` | `"event.category"` |
| `LISTENER_ID` | `"listener.id"` |
| `BUS_ID` | `"bus.id"` |
## Implementation Notes
The constants are defined in `src/constants/index.ts` and re-exported from this module to follow the package structure convention. Consumers should import from `@suaveplan/events-metrics` (the root barrel) rather than the internal path to benefit from tree-shaking and future path changes.

View File

@@ -0,0 +1,33 @@
# @suaveplan/events-otel - OpenTelemetry tracing companion for @suaveplan/events: wraps each dispatch in a span, propagates W3C Trace Context, and records listener exceptions as span errors.
## Summary
OpenTelemetry tracing companion for `@suaveplan/events`. Wraps each dispatch in a span, propagates W3C Trace Context, and records listener exceptions as span errors.
<!-- begin:auto-docs -->
## Documentation tree
- [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-tracing](./src/core/attach-event-tracing/attach-event-tracing.md) — <!-- begin:auto-docs --> _(no submodules)_- [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [trace-context](./src/propagation/trace-context/trace-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sampling-rule](./src/sampling/sampling-rule/sampling-rule.md) — <!-- begin:auto-docs --> _(no submodules)_
## Quick Start
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventTracing } from "@suaveplan/events-otel";
const bus = new EventBus();
const handle = attachEventTracing(bus);
await bus.emitAsync("user.created", { id: "u-1" });
handle.detach();
```
## Modules
- [`src/core/attach-event-tracing/attach-event-tracing`](src/core/attach-event-tracing/attach-event-tracing.md) — `attachEventTracing`, `EventTracingHandle`, `EventTracingOptions`
- [`src/propagation/trace-context/trace-context`](src/propagation/trace-context/trace-context.md) — `serializeTraceContext`, `deserializeTraceContext`
- [`src/sampling/sampling-rule/sampling-rule`](src/sampling/sampling-rule/sampling-rule.md) — `SamplingRule`, `shouldSample`, `SamplingRuleSchema`

View File

@@ -0,0 +1,87 @@
# attach-event-tracing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Attaches OTel tracing instrumentation to an `EventBus` via the public interceptor API from `@suaveplan/events`. Each dispatch produces a span named `events.dispatch` with event name and event ID attributes. Listener exceptions are recorded on the span as errors before being re-thrown. The interceptor captures the active context at dispatch time so that any OTel operations inside listeners produce correctly parented child spans.
The implementation is sampling-aware: it evaluates the configured `SamplingRule` to decide whether to create a span. When no sampling rule is configured it defaults to `always`.
## Features
- `attachEventTracing(bus, options?)` — installs a tracing interceptor and returns `EventTracingHandle`
- Span per dispatch (`events.dispatch`) with `suaveplan.event.name` and `suaveplan.event.id` attributes
- Listener exception → `span.recordException(err)` + `SpanStatusCode.ERROR`
- Sampling rule support (`always`, `never`, `rate`, `event-name`)
- `eventFilter` option to exclude event types from tracing
- Injectable `tracer`, `propagator`, `clock`, `logger` for testability
- `EventTracingHandle.detach()` removes the interceptor
## Usage: Basic Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventTracing } from "@suaveplan/events-otel";
const bus = new EventBus<{ "user.created": { id: string } }>();
const handle = attachEventTracing(bus);
bus.on("user.created", (p) => console.log("created", p.id));
await bus.emitAsync("user.created", { id: "u-1" });
// A span named "events.dispatch" was created
handle.detach();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventTracing } from "@suaveplan/events-otel";
import { trace } from "@opentelemetry/api";
const bus = new EventBus();
const handle = attachEventTracing(bus, {
tracer: trace.getTracer("my-app"),
samplingRule: { type: "rate", rate: 0.1 },
eventFilter: (name) => !name.startsWith("internal."),
});
// Only 10% of non-internal event dispatches produce spans
await bus.emitAsync("user.created", { id: "u-2" });
handle.detach();
```
## API Reference
### `attachEventTracing<TMap>(bus, options?): EventTracingHandle`
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus<TMap>` | The event bus to instrument |
| `options` | `EventTracingOptions?` | Tracer, propagator, filter, sampling rule, clock, logger |
**Returns**: `EventTracingHandle`
### `EventTracingHandle`
| Method | Returns | Description |
|---|---|---|
| `detach()` | `void` | Remove the tracing interceptor |
### `EventTracingOptions`
| Field | Type | Description |
|---|---|---|
| `tracer` | `Tracer?` | OTel tracer (default: global) |
| `propagator` | `TextMapPropagator?` | W3C propagator (default: global) |
| `eventFilter` | `(name: string) => boolean` | Exclude event names from tracing |
| `samplingRule` | `SamplingRule?` | Sampling strategy (default: `always`) |
| `clock` | `Clock?` | Injectable clock |
| `logger` | `LoggerLike?` | Diagnostic logger |
## Implementation Notes
The interceptor uses `tracer.startActiveSpan(SPAN_NAMES.DISPATCH, {}, context.active(), ...)` to both create the span and set it as the active context for the duration of `next()`. This ensures all OTel operations within listeners (including nested dispatches) are correctly parented. The `context.active()` snapshot happens synchronously at interceptor entry, so even if the active context changes between dispatch and listener execution, the span retains the correct parent.

View File

@@ -0,0 +1,70 @@
# trace-context
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `serializeTraceContext` and `deserializeTraceContext` helpers for `@suaveplan/events-otel`. These functions convert the W3C Trace Context (traceparent + tracestate headers) to and from a plain `Record<string, string>` that can be safely embedded in event payloads, transport envelopes, or message-queue headers without importing OTel types.
The helpers delegate to the `@opentelemetry/api` global propagator, so the exact propagation format (W3C, B3, Jaeger, etc.) is controlled by the installed SDK — the application does not need to change this package to switch formats.
## Features
- `serializeTraceContext(ctx?)` — extracts active W3C trace context into a plain map; injectable context for testing
- `deserializeTraceContext(headers, parentCtx?)` — restores context from a map; injectable parent context for nesting
- Zero runtime state — both functions use the global OTel propagator and context APIs
## Usage: Basic Example
```ts
import { serializeTraceContext, deserializeTraceContext } from "@suaveplan/events-otel";
// Sender side: capture context before cross-boundary send
const headers = serializeTraceContext();
// { "traceparent": "00-abc...123-0000000000000001-01" }
// Receiver side: restore context before re-dispatching
const ctx = deserializeTraceContext(headers);
```
## Usage: Advanced Example
```ts
import { serializeTraceContext, deserializeTraceContext } from "@suaveplan/events-otel";
import { context, trace } from "@opentelemetry/api";
// Sender: extract context from a specific span
const span = trace.getActiveSpan();
const ctx = span ? trace.setSpan(context.active(), span) : context.active();
const headers = serializeTraceContext(ctx);
// Receiver: restore and use as parent
const restored = deserializeTraceContext(headers);
context.with(restored, () => {
// Any spans created here will be children of the sender's span
});
```
## API Reference
### `serializeTraceContext(ctx?: Context): Record<string, string>`
| Param | Type | Description |
|---|---|---|
| `ctx` | `Context?` | OTel context to serialize (default: `context.active()`) |
**Returns**: plain map of propagation headers (e.g., `{ "traceparent": "..." }`)
### `deserializeTraceContext(headers, parentCtx?): Context`
| Param | Type | Description |
|---|---|---|
| `headers` | `Record<string, string>` | Map of propagation headers |
| `parentCtx` | `Context?` | Parent OTel context (default: `context.active()`) |
**Returns**: OTel `Context` with the trace context restored; returns root context if headers contain no valid trace info.
## Implementation Notes
Both functions use the global OTel propagation API (`propagation.inject` / `propagation.extract`). When no OTel SDK is installed, the global propagator is a no-op, so `serializeTraceContext` returns an empty object and `deserializeTraceContext` returns the active context unchanged. This ensures the package is safe to use in environments without OTel.

View File

@@ -0,0 +1,75 @@
# sampling-rule
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `SamplingRule` discriminated union type and the `shouldSample` evaluation function for `@suaveplan/events-otel`. Sampling rules determine which event dispatches produce OpenTelemetry spans, enabling cost-efficient tracing in high-throughput production environments without instrumentation code changes.
The implementation is pure and injectable: `shouldSample` accepts a random-number generator parameter so rate-based sampling can be tested deterministically.
## Features
- `SamplingRule` — four-variant discriminated union: `always`, `never`, `rate`, `event-name`
- `SamplingRuleSchema` — Zod schema for validating rule objects at the boundary
- `shouldSample(rule, eventName, rand?)` — evaluates a rule against a given event name and optional RNG
## Usage: Basic Example
```ts
import { shouldSample } from "@suaveplan/events-otel";
import type { SamplingRule } from "@suaveplan/events-otel";
const rule: SamplingRule = { type: "event-name", names: ["user.created", "order.placed"] };
shouldSample(rule, "user.created"); // true
shouldSample(rule, "page.viewed"); // false
```
## Usage: Advanced Example
```ts
import { shouldSample, SamplingRuleSchema } from "@suaveplan/events-otel";
// Validate from config
const rawRule = { type: "rate", rate: 0.1 };
const rule = SamplingRuleSchema.parse(rawRule);
// Deterministic test: always sample
const alwaysSample = () => 0.05; // < 0.1
shouldSample(rule, "any.event", alwaysSample); // true
// Deterministic test: never sample
const neverSample = () => 0.95; // >= 0.1
shouldSample(rule, "any.event", neverSample); // false
```
## API Reference
### `shouldSample(rule, eventName, rand?): boolean`
| Param | Type | Description |
|---|---|---|
| `rule` | `SamplingRule` | Rule to evaluate |
| `eventName` | `string` | Name of the dispatched event |
| `rand` | `() => number` | RNG for rate sampling (default: `Math.random`) |
**Returns**: `true` if the dispatch should produce a span.
### `SamplingRuleSchema`
Zod schema validating the `SamplingRule` discriminated union. Use for config parsing.
### `SamplingRule`
```ts
type SamplingRule =
| { type: "always" }
| { type: "never" }
| { type: "rate"; rate: number } // 0.0 – 1.0
| { type: "event-name"; names: string[] }
```
## Implementation Notes
Rate-based sampling uses a simple comparison `rand() < rule.rate` without any consistent hashing. For consistent per-trace sampling (where all spans in one trace are either all sampled or all dropped), the caller should check the parent span's sampling flag first — the `attachEventTracing` function does this by only evaluating `shouldSample` when there is no parent span in context.

View File

@@ -0,0 +1,31 @@
# @suaveplan/events-schema - Runtime schema-validation companion for @suaveplan/events: validates event payloads at dispatch boundaries via Zod schemas with strict/warn/sample modes and sensitive field redaction.
## Summary
Runtime schema-validation companion for `@suaveplan/events`. Validates event payloads at dispatch boundaries via Zod schemas with strict/warn/sample modes and sensitive field redaction.
<!-- begin:auto-docs -->
## Documentation tree
- [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-schemas](./src/core/attach-event-schemas/attach-event-schemas.md) — <!-- begin:auto-docs --> _(no submodules)_
## Quick Start
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventSchemas } from "@suaveplan/events-schema";
import z from "zod";
const bus = new EventBus();
const handle = attachEventSchemas(bus, { mode: "strict" });
handle.registerSchema("user.created", z.object({ id: z.string() }));
await bus.emitAsync("user.created", { id: "u-1" }); // passes
await bus.emitAsync("user.created", { id: 123 }); // throws EventsSchemaError
handle.detach();
```
## Modules
- [`src/core/attach-event-schemas/attach-event-schemas`](src/core/attach-event-schemas/attach-event-schemas.md) — `attachEventSchemas`, `registerEventSchema`, `EventSchemasHandle`

View File

@@ -0,0 +1,97 @@
# attach-event-schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Attaches runtime schema-validation instrumentation to an `EventBus` via the public interceptor API from `@suaveplan/events`. On each dispatch, it validates the event payload against a Zod schema registered for that event name. Three validation modes provide flexibility across different deployment stages: `"strict"` rejects the dispatch with a structured error (suitable for CI and development), `"warn"` logs the rejection but lets the event through (suitable for canary rollout), and `"sample"` validates a configurable fraction of events (suitable for high-throughput production paths).
Validation statistics are tracked in-process and available without an OTel exporter via `EventSchemasHandle.stats()`. OTel counters and histograms are also emitted for dashboard visibility.
## Features
- `attachEventSchemas(bus, options?)` — installs a schema-validation interceptor and returns `EventSchemasHandle`
- Three validation modes: `strict`, `warn`, `sample`
- `EventSchemasHandle.registerSchema(eventName, schema, version?)` — registers/updates a Zod schema
- `EventSchemasHandle.stats()` — returns in-process validation statistics
- Sensitive field redaction in rejection output (opt-in via `redactSensitive: true`)
- OTel counters: `events_schema.validations_total`, `events_schema.validations_failed_total`, `events_schema.unknown_schema_total`
- OTel histogram: `events_schema.validation_duration_ms`
- `registerEventSchema(registry, eventName, schema, options?)` — standalone registration helper
- Injectable `registry`, `clock`, `rng`, `logger` for testability
## Usage: Basic Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventSchemas } from "@suaveplan/events-schema";
import z from "zod";
const bus = new EventBus<{ "user.created": { id: string } }>();
const handle = attachEventSchemas(bus);
handle.registerSchema("user.created", z.object({ id: z.string() }));
bus.on("user.created", (p) => console.log("created", p.id));
await bus.emitAsync("user.created", { id: "u-1" }); // passes
handle.detach();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventSchemas, registerEventSchema } from "@suaveplan/events-schema";
import { SchemaRegistry } from "@suaveplan/schema-registry";
import z from "zod";
const registry = new SchemaRegistry();
const bus = new EventBus();
const handle = attachEventSchemas(bus, {
registry,
mode: "warn",
onRejection: (r) => console.warn("REJECTED", r.eventName, r.errors),
redactSensitive: true,
});
handle.registerSchema("order.placed", z.object({
orderId: z.string(),
amount: z.number().positive(),
}));
await bus.emitAsync("order.placed", { orderId: "o-1", amount: -5 });
// Logs a warning but does NOT throw (warn mode)
console.log(handle.stats().rejectionsTotal["order.placed"]); // 1
handle.detach();
```
## API Reference
### `attachEventSchemas<TMap>(bus, options?): EventSchemasHandle`
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus<TMap>` | The event bus to instrument |
| `options` | `EventSchemasOptions?` | Registry, mode, sampleRate, onRejection, redact, clock, rng, logger |
**Returns**: `EventSchemasHandle`
### `registerEventSchema(registry, eventName, schema, options?): void`
Standalone helper for registering schemas in a `SchemaRegistry` before attaching.
### `EventSchemasHandle`
| Method | Returns | Description |
|---|---|---|
| `detach()` | `void` | Remove the interceptor |
| `registerSchema(eventName, schema, version?)` | `void` | Register a Zod schema |
| `stats()` | `SchemaValidationStats` | In-process statistics |
## Implementation Notes
Schemas registered via `handle.registerSchema` are stored in an in-memory `Map` in addition to the registry. This avoids the registry's compatibility enforcement on the first registration (which would fail with `SchemaRegistryError` for new subjects). Subsequent registrations via `registerSchema` go through the registry's compatibility check. The `sample` mode evaluates the random gate before schema lookup to avoid the lookup cost for unsampled dispatches. The `rng.next()` call uses the injectable `Rng` interface from `@suaveplan/types` for deterministic testing.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,77 @@
# envelope
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `TransportEnvelope` wire format and provides `buildEnvelope` / `parseEnvelope` helpers for `@suaveplan/events-transport`. The envelope is the serialisable container that crosses context boundaries (worker threads, browser tabs). Every envelope carries a version tag, event name, structured-clone-safe payload, a dedup nonce, sender origin, timestamp, and optional W3C trace context headers.
The Zod schema validates all incoming envelopes at the receive boundary, throwing `EventsTransportError(EVENTS_TRANSPORT.ENVELOPE_INVALID)` for malformed data. This prevents processing corrupt or malicious messages from untrusted channels.
## Features
- `TransportEnvelope` — wire format type with v1 literal version tag
- `TransportEnvelopeSchema` — Zod schema for validation and coercion
- `buildEnvelope(event, payload, options)` — creates a typed envelope
- `parseEnvelope(raw)` — validates and returns `TransportEnvelope` or throws
## Usage: Basic Example
```ts
import { buildEnvelope, parseEnvelope } from "@suaveplan/events-transport";
const env = buildEnvelope("order.placed", { orderId: "o-1" }, {
nonce: crypto.randomUUID(),
origin: "tab-main",
ts: Date.now(),
});
const parsed = parseEnvelope(JSON.parse(JSON.stringify(env)));
console.log(parsed.event); // "order.placed"
```
## Usage: Advanced Example
```ts
import { buildEnvelope, parseEnvelope, TransportEnvelopeSchema } from "@suaveplan/events-transport";
// Build with trace context
const env = buildEnvelope("user.created", { id: "u-1" }, {
nonce: "abc-123",
origin: "worker-1",
ts: Date.now(),
traceContext: { traceparent: "00-abc...123-01" },
});
// Validate manually
const result = TransportEnvelopeSchema.safeParse(env);
if (!result.success) {
console.error("invalid envelope");
}
```
## API Reference
### `buildEnvelope(event, payload, options): TransportEnvelope`
| Param | Type | Description |
|---|---|---|
| `event` | `string` | Event name |
| `payload` | `unknown` | Structured-clone-safe payload |
| `options.nonce` | `string` | Unique dedup key |
| `options.origin` | `string` | Sender identifier |
| `options.ts` | `number` | Sender timestamp (ms) |
| `options.traceContext` | `Record<string,string>?` | W3C trace context headers |
### `parseEnvelope(raw): TransportEnvelope`
**Throws**: `EventsTransportError(EVENTS_TRANSPORT.ENVELOPE_INVALID)` if validation fails.
### `TransportEnvelopeSchema`
Zod schema for `TransportEnvelope`. Use directly when integrating with other Zod schemas.
## Implementation Notes
`payload: z.unknown()` in the schema means the payload is not validated structurally — that is left to the event schema validation layer (`@suaveplan/events-schema`). The envelope only validates the envelope _frame_ (version, nonce, origin, ts), not the payload content. This separation keeps the transport layer payload-agnostic.

View File

@@ -0,0 +1,66 @@
# nonce-window
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `NonceWindow`, a sliding-window LRU set for deduplicating transport envelopes by nonce. Both `MessageChannelTransport` and `BroadcastChannelTransport` use it to silently drop duplicate envelopes that arrive within the dedup window, which prevents double-processing under at-least-once delivery (retries) or network-level message duplication.
The window size is configurable — larger windows catch duplicates over longer time horizons at the cost of memory; smaller windows are cheaper but may miss duplicates that arrive after the window has evicted them.
## Features
- `NonceWindow(windowSize?)` — LRU sliding window; default size 1000
- `check(nonce): boolean` — returns `true` (duplicate) or `false` (first-seen); records the nonce if not a duplicate
- `size` getter — current number of tracked nonces
- `clear()` — resets the window
## Usage: Basic Example
```ts
import { NonceWindow } from "@suaveplan/events-transport";
const window = new NonceWindow(100);
window.check("nonce-1"); // false (new)
window.check("nonce-1"); // true (duplicate)
window.check("nonce-2"); // false (new)
```
## Usage: Advanced Example
```ts
import { NonceWindow } from "@suaveplan/events-transport";
// Fill to capacity and verify LRU eviction
const window = new NonceWindow(2);
window.check("a"); // false
window.check("b"); // false
window.check("c"); // false — evicts "a"
window.check("a"); // false — "a" was evicted, treated as new
window.check("b"); // true — "b" still in window
```
## API Reference
### `new NonceWindow(windowSize?: number)`
| Param | Type | Description |
|---|---|---|
| `windowSize` | `number?` | Max nonces to track (default: 1000) |
### `check(nonce: string): boolean`
Records `nonce` if not seen. Returns `true` if already in the window (duplicate), `false` if new.
### `size: number`
Current count of tracked nonces.
### `clear(): void`
Empties the window; all subsequent nonces will be treated as new.
## Implementation Notes
The implementation uses a `string[]` ring-buffer and a `Set<string>` for O(1) lookup. On eviction, the oldest entry is `shift()`ed from the array and deleted from the set. This gives O(1) amortized `check` for the common case. A more memory-efficient LRU cache could use a doubly-linked list + Map for O(1) worst-case, but for window sizes of 1000 the array approach is faster in practice due to cache locality.

View File

@@ -0,0 +1,79 @@
# transport-interface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the core `EventTransport` interface and supporting types (`ConnectOptions`, `TransportHandle`, `TransportStats`, `DeliveryGuarantee`) that all transport implementations in `@suaveplan/events-transport` must satisfy. This interface layer is pure types — no runtime code — enabling consumers to depend on it for typing without pulling in any implementation.
The design follows a connect-and-disconnect pattern: `transport.connect(bus, options?)` returns a `TransportHandle` with `disconnect()` and a `stats` snapshot. The `deliveryGuarantee` property documents the transport's contract so callers can make informed decisions about idempotency and retry at the application layer.
## Features
- `EventTransport` interface with `name`, `deliveryGuarantee`, and `connect`
- `TransportHandle` with `disconnect()` and `stats` snapshot
- `TransportStats` with `sent`, `received`, `dropped`, `duplicatesFiltered` counters
- `DeliveryGuarantee` — `"at-most-once"` or `"at-least-once"`
- `ConnectOptions` — `eventFilter`, `signal`, `logger`, `clock`, `rng`, `idGenerator`
## Usage: Basic Example
```ts
import type { EventTransport, TransportHandle } from "@suaveplan/events-transport";
function withTransport(bus: EventBus, transport: EventTransport): () => void {
const handle: TransportHandle = transport.connect(bus);
console.log("guarantee:", transport.deliveryGuarantee);
return () => handle.disconnect();
}
```
## Usage: Advanced Example
```ts
import type { EventTransport, ConnectOptions } from "@suaveplan/events-transport";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus();
const transport: EventTransport = createMessageChannelTransport({ channel: new MessageChannel() });
const handle = transport.connect(bus, {
eventFilter: (name) => name.startsWith("order."),
signal: AbortSignal.timeout(60_000),
});
console.log(handle.stats.sent); // 0 initially
console.log(handle.stats.received); // 0 initially
handle.disconnect();
```
## API Reference
### `EventTransport`
| Member | Type | Description |
|---|---|---|
| `name` | `string` | Transport identifier |
| `deliveryGuarantee` | `DeliveryGuarantee` | Delivery contract |
| `connect(bus, options?)` | `TransportHandle` | Attach transport to the bus |
### `TransportHandle`
| Member | Type | Description |
|---|---|---|
| `disconnect()` | `void` | Detach from the bus |
| `stats` | `TransportStats` | Live statistics |
### `TransportStats`
| Field | Type | Description |
|---|---|---|
| `sent` | `number` | Envelopes sent |
| `received` | `number` | Envelopes received |
| `dropped` | `number` | Envelopes dropped (non-cloneable, etc.) |
| `duplicatesFiltered` | `number` | Duplicate nonces filtered |
## Implementation Notes
The `EventTransport.connect` method accepts an `EventBus<EventMap>` (the widest type) rather than a specific generic type. This avoids TypeScript covariance issues between different `EventBus<TMap>` types at the cost of losing per-event-type safety at the transport boundary. Type safety is enforced by the event bus itself when re-dispatching received payloads.

View File

@@ -0,0 +1,124 @@
# broadcast-channel-transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements an `EventTransport` over the browser `BroadcastChannel` API, enabling
**at-most-once** event fan-out to every browser context (tab, worker, iframe) that has opened
a `BroadcastChannel` with the same channel name. Unlike the `MessageChannel` transport, no
acknowledgement is exchanged — delivery is fire-and-forget. This makes it ideal for cache
invalidation, UI refresh signals, and other scenarios where occasional loss is acceptable and
simplicity is preferred over reliability.
The transport filters out messages it sent itself (`origin === "local"`) to prevent event
loops, and uses a `NonceWindow` ring buffer to deduplicate retransmissions from other
transports bridged to the same bus.
## Features
- At-most-once delivery — no ack/retry overhead
- Same-origin loop prevention via `origin` tag in envelope
- Nonce-based duplicate filtering (configurable sliding-window)
- Payload safety check — non-structured-cloneable values are dropped rather than throwing
- AbortSignal support for clean shutdown
- Injectable clock, ID generator, and event filter via `ConnectOptions`
- Per-transport statistics: `sent`, `received`, `dropped`, `duplicatesFiltered`
## Usage: Basic Example
```ts
import { createBroadcastChannelTransport } from "@suaveplan/events-transport/transports/broadcast-channel/broadcast-channel-transport";
import { EventBus } from "@suaveplan/events";
const channel = new BroadcastChannel("app-events");
const bus = new EventBus<{ "cache.invalidated": { key: string } }>();
const transport = createBroadcastChannelTransport({ channel });
const handle = transport.connect(bus);
bus.on("cache.invalidated", (p) => console.log("invalidate", p.key));
await bus.emitAsync("cache.invalidated", { key: "user:42" });
// Event is broadcast to all other contexts on the same channel name
handle.disconnect();
channel.close();
```
## Usage: Advanced Example
```ts
import { createBroadcastChannelTransport } from "@suaveplan/events-transport/transports/broadcast-channel/broadcast-channel-transport";
import { EventBus } from "@suaveplan/events";
const channel = new BroadcastChannel("notifications");
const bus = new EventBus<{ "notification.show": { msg: string } }>();
const ac = new AbortController();
const transport = createBroadcastChannelTransport({
channel,
nonceWindowSize: 200,
});
const handle = transport.connect(bus, {
signal: ac.signal,
eventFilter: (name) => name.startsWith("notification."),
clock: { now: () => performance.now() },
idGenerator: { generate: () => crypto.randomUUID() },
});
bus.on("notification.show", (p) => console.log(p.msg));
await bus.emitAsync("notification.show", { msg: "Hello from another tab!" });
console.log(handle.stats.sent); // 1
ac.abort();
handle.disconnect();
channel.close();
```
## API Reference
### `createBroadcastChannelTransport(options): EventTransport`
| Param | Type | Description |
|---|---|---|
| `options.channel` | `BroadcastChannel` | The broadcast channel to use |
| `options.nonceWindowSize` | `number?` | Dedup window size. Default: `1000` |
**Returns**: `EventTransport` with `deliveryGuarantee: "at-most-once"`
### `EventTransport.connect(bus, options?): TransportHandle`
Connects the transport to an `EventBus`. Installs a `bus.use()` interceptor to capture
outbound dispatches and a `channel.onmessage` listener for inbound envelopes.
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus` | The bus to bridge |
| `options.eventFilter` | `(name: string) => boolean` | Filter events. Default: all events |
| `options.signal` | `AbortSignal?` | Cancellation |
| `options.clock` | `Clock?` | Injectable clock |
| `options.idGenerator` | `IdGenerator?` | Injectable nonce generator |
### `TransportHandle`
| Member | Type | Description |
|---|---|---|
| `disconnect()` | `() => void` | Uninstall interceptor and listener |
| `stats` | `TransportStats` | Snapshot of sent/received/dropped/duplicatesFiltered counts |
## Implementation Notes
Because `BroadcastChannel` delivers messages to every context **except** the sender, this
transport does not need to track pending acks. However, the `origin: "local"` tag is still
set on outbound envelopes so that if two tabs have the same logical bus connected via the
same `BroadcastChannel`, a message sent from tab A will not be re-dispatched by tab A's
own `onMessage` listener (which would cause a local loop if the same channel name were
somehow looped back).
The dedup window guards against scenarios where multiple transports are bridged together —
for example, a `MessageChannel` transport forwarding into a `BroadcastChannel` transport —
where the same nonce could arrive from multiple paths.

View File

@@ -0,0 +1,127 @@
# message-channel-transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements an `EventTransport` over the browser and Node.js `MessageChannel` API, providing
bidirectional, structured-cloneable event forwarding between two `MessagePort` endpoints with
**at-least-once delivery** semantics. Every outbound event envelope is posted to `port1` and
the sender waits for an explicit acknowledgement from the receiver on `port2` before recording
the send as successful. If the ack does not arrive within the configured timeout, the message
is retried up to `maxRetries` times; unacked messages are tracked as dropped in the transport
statistics.
## Features
- At-least-once delivery via ack/retry protocol
- Nonce-based duplicate filtering on the receive side (configurable sliding-window size)
- Payload safety check — non-structured-cloneable values are dropped rather than throwing
- Per-transport statistics: `sent`, `received`, `dropped`, `duplicatesFiltered`
- AbortSignal support: stops outbound sends and inbound processing after abort
- Injectable clock, ID generator, and event filter via `ConnectOptions`
- Clean disconnect with pending ack cleanup
## Usage: Basic Example
```ts
import { createMessageChannelTransport } from "@suaveplan/events-transport/transports/message-channel/message-channel-transport";
import { EventBus } from "@suaveplan/events";
const channel = new MessageChannel();
const bus = new EventBus<{ "user.created": { id: string } }>();
const transport = createMessageChannelTransport({ channel });
const handle = transport.connect(bus);
bus.on("user.created", (p) => console.log("received", p.id));
await bus.emitAsync("user.created", { id: "u-1" });
console.log(handle.stats.sent); // 1
handle.disconnect();
```
## Usage: Advanced Example
```ts
import { createMessageChannelTransport } from "@suaveplan/events-transport/transports/message-channel/message-channel-transport";
import { EventBus } from "@suaveplan/events";
const channel = new MessageChannel();
const busA = new EventBus<{ ping: { seq: number } }>();
const busB = new EventBus<{ ping: { seq: number } }>();
const ac = new AbortController();
// Wire busA's port1 to busB's port2 via a shared channel
const transportA = createMessageChannelTransport({
channel,
ackTimeoutMs: 2000,
maxRetries: 5,
nonceWindowSize: 500,
});
const handleA = transportA.connect(busA, {
signal: ac.signal,
eventFilter: (name) => name.startsWith("ping"),
});
// busB receives events
busB.on("ping", (p) => console.log("busB got ping", p.seq));
// busA emits, arrives on busB
await busA.emitAsync("ping", { seq: 1 });
console.log(handleA.stats); // { sent: 1, received: 0, dropped: 0, duplicatesFiltered: 0 }
ac.abort();
handleA.disconnect();
```
## API Reference
### `createMessageChannelTransport(options): EventTransport`
| Param | Type | Description |
|---|---|---|
| `options.channel` | `MessageChannel` | The channel to communicate over |
| `options.ackTimeoutMs` | `number?` | Ack wait timeout in ms. Default: `5000` |
| `options.maxRetries` | `number?` | Max retries before drop. Default: `3` |
| `options.nonceWindowSize` | `number?` | Dedup window size. Default: `1000` |
**Returns**: `EventTransport` with `deliveryGuarantee: "at-least-once"`
### `EventTransport.connect(bus, options?): TransportHandle`
Connects the transport to an `EventBus`. Installs a `bus.use()` interceptor to capture outbound
dispatches and a `port2` message listener to receive inbound envelopes.
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus` | The bus to bridge |
| `options.eventFilter` | `(name: string) => boolean` | Filter events. Default: all events |
| `options.signal` | `AbortSignal?` | Cancellation |
| `options.clock` | `Clock?` | Injectable clock |
| `options.idGenerator` | `IdGenerator?` | Injectable nonce generator |
### `TransportHandle`
| Member | Type | Description |
|---|---|---|
| `disconnect()` | `() => void` | Uninstall interceptor, stop listener, clear pending acks |
| `stats` | `TransportStats` | Snapshot of sent/received/dropped/duplicatesFiltered counts |
## Implementation Notes
The ack protocol uses a `Map<string, () => void>` of pending ack resolvers keyed by nonce.
When `port1.postMessage(envelope)` is sent, a `Promise<boolean>` is created that resolves
`true` when the matching `{ ack: nonce }` reply arrives on `port2`, or `false` when the
timeout fires. The retry loop retries the full postMessage on each `false` result.
Non-structured-cloneable payloads are silently dropped (incrementing `stats.dropped`) rather
than throwing because the interceptor runs after `next()` — the local dispatch has already
succeeded. Throwing here would be misleading since the event was processed locally.
Duplicate filtering on receive uses a `NonceWindow` ring buffer so the sender can safely retry
without the receiver processing the same event twice.

View File

@@ -0,0 +1,64 @@
# structured-clone-check
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `isStructuredCloneable` and `assertStructuredCloneable` utilities for `@suaveplan/events-transport`. These functions validate that an event payload can be safely transmitted across context boundaries via the structured clone algorithm used by `MessageChannel` and `BroadcastChannel`. Payloads that fail the check are dropped before transmission to prevent runtime `DataCloneError` exceptions.
The check is conservative — it allows all primitives, plain objects, arrays, and well-known cloneable builtins (Date, RegExp, Map, Set, ArrayBuffer, TypedArrays, Error), and rejects functions, symbols, WeakMap, WeakSet, and class instances with custom prototypes.
## Features
- `isStructuredCloneable(value)` — recursive check for clone-safety
- `assertStructuredCloneable(value)` — throws `EventsTransportError(NOT_CLONEABLE)` on failure
- Handles nested objects and arrays
- Allows Date, RegExp, Map, Set, ArrayBuffer, TypedArray, Error
## Usage: Basic Example
```ts
import { isStructuredCloneable } from "@suaveplan/events-transport";
isStructuredCloneable({ id: "x", count: 1 }); // true
isStructuredCloneable(() => console.log("fn")); // false
isStructuredCloneable(Symbol("sym")); // false
isStructuredCloneable(new Date()); // true
isStructuredCloneable(new Map([["k", "v"]])); // true
```
## Usage: Advanced Example
```ts
import { assertStructuredCloneable, isStructuredCloneable } from "@suaveplan/events-transport";
import { EventsTransportError } from "@suaveplan/events-transport";
// Assert before transmission
try {
assertStructuredCloneable({ fn: () => {} });
} catch (err) {
if (err instanceof EventsTransportError) {
console.log(err.code); // "EVENTS_TRANSPORT.NOT_CLONEABLE"
}
}
// Safe payloads
isStructuredCloneable(new Uint8Array([1, 2, 3])); // true (TypedArray)
isStructuredCloneable(new Set([1, 2, 3])); // true (Set)
isStructuredCloneable(new WeakMap()); // false
```
## API Reference
### `isStructuredCloneable(value: unknown): boolean`
Returns `true` if the value can be transmitted via structured clone.
### `assertStructuredCloneable(value: unknown): void`
**Throws**: `EventsTransportError(EVENTS_TRANSPORT.NOT_CLONEABLE)` if value is not cloneable.
## Implementation Notes
The check uses `instanceof` for known builtins and `Object.getPrototypeOf` to detect class instances. It is recursive for arrays and plain objects. Custom class instances (those with a non-Object, non-null prototype that are not whitelisted) are rejected as non-cloneable. This is a conservative heuristic — some custom classes may be cloneable if they implement the `[Symbol.for("nodejs.util.inspect.custom")]` or structuredClone algorithm, but the safe default is to reject them. The actual `structuredClone()` call is not used for the check to avoid the cost of deep-cloning on every dispatch.

View File

@@ -0,0 +1,121 @@
# @suaveplan/events - Typed event-bus substrate: pub/sub with deterministic FIFO, interceptor API, bounded-queue backpressure, idempotent listeners, Zod-validated envelopes, passive observability adapter.
## Summary
Typed event-bus substrate for Suaveplan. Pub/sub with deterministic FIFO delivery, interceptor API, bounded-queue backpressure, idempotent listeners, Zod-validated envelopes, and a passive observability adapter.
<!-- begin:auto-docs -->
## Documentation tree
- [bus](./src/bus/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [event-bus](./src/bus/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [clock](./src/clock/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [default-clock](./src/clock/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [codes](./src/errors/codes.md) — `EVENTS_CODES` is the frozen registry of every failure code `@suaveplan/events` can raise. All codes share the `EVENTS.…
- [events-error](./src/errors/events-error.md) — `EventsError` is the single concrete error class thrown or captured by `@suaveplan/events`. It extends `SuaveplanError`…
- [id](./src/id/id-generator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [id-generator](./src/id/id-generator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [idempotent](./src/idempotent/idempotent.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [idempotent](./src/idempotent/idempotent.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [interceptor](./src/interceptor/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compose](./src/interceptor/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [with-observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queue](./src/queue/bounded-queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bounded-queue](./src/queue/bounded-queue.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [event-bus](./src/bus/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [clock](./src/clock/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-clock](./src/clock/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `EVENTS_CODES` is the frozen registry of every failure code `@suaveplan/events` can raise. All codes share the `EVENTS.…
- [events-error](./src/errors/events-error.md) — `EventsError` is the single concrete error class thrown or captured by `@suaveplan/events`. It extends `SuaveplanError`…
- [id](./src/id/id-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [id-generator](./src/id/id-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotent](./src/idempotent/idempotent.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotent](./src/idempotent/idempotent.md) — <!-- begin:auto-docs --> _(no submodules)_- [interceptor](./src/interceptor/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/interceptor/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [with-observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/bounded-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [bounded-queue](./src/queue/bounded-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/bus/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [clock](./src/clock/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-clock](./src/clock/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `EVENTS_CODES` is the frozen registry of every failure code `@suaveplan/events` can raise. All codes share the `EVENTS.…
- [events-error](./src/errors/events-error.md) — `EventsError` is the single concrete error class thrown or captured by `@suaveplan/events`. It extends `SuaveplanError`…
- [id](./src/id/id-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [id-generator](./src/id/id-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotent](./src/idempotent/idempotent.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotent](./src/idempotent/idempotent.md) — <!-- begin:auto-docs --> _(no submodules)_- [interceptor](./src/interceptor/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/interceptor/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [with-observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/bounded-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [bounded-queue](./src/queue/bounded-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/bus/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [clock](./src/clock/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-clock](./src/clock/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `EVENTS_CODES` is the frozen registry of every failure code `@suaveplan/events` can raise. All codes share the `EVENTS.…
- [events-error](./src/errors/events-error.md) — `EventsError` is the single concrete error class thrown or captured by `@suaveplan/events`. It extends `SuaveplanError`…
- [id](./src/id/id-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [id-generator](./src/id/id-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotent](./src/idempotent/idempotent.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotent](./src/idempotent/idempotent.md) — <!-- begin:auto-docs --> _(no submodules)_- [interceptor](./src/interceptor/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/interceptor/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [with-observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/bounded-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [bounded-queue](./src/queue/bounded-queue.md) — <!-- begin:auto-docs --> _(no submodules)_- [event-bus](./src/bus/event-bus.md) — <!-- begin:auto-docs --> _(no submodules)_- [clock](./src/clock/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ - [default-clock](./src/clock/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ - [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `EVENTS_CODES` is the frozen registry of every failure code `@suaveplan/events` can raise. All codes share the `EVENTS.…
- [events-error](./src/errors/events-error.md) — `EventsError` is the single concrete error class thrown or captured by `@suaveplan/events`. It extends `SuaveplanError`…
- [id](./src/id/id-generator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [id-generator](./src/id/id-generator.md) — <!-- begin:auto-docs --> _(no submodules)_- [idempotent](./src/idempotent/idempotent.md) — <!-- begin:auto-docs --> _(no submodules)_ - [idempotent](./src/idempotent/idempotent.md) — <!-- begin:auto-docs --> _(no submodules)_- [interceptor](./src/interceptor/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ - [compose](./src/interceptor/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [with-observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [queue](./src/queue/bounded-queue.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bounded-queue](./src/queue/bounded-queue.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/events
```
## Why
Downstream packages (`events-metrics`, `events-otel`, `events-schema`, `events-transport`) attach through `bus.use(interceptor)` or `withObservability(bus, adapter)` — the core never couples to any of them.
## Usage
```ts
import { EventBus } from "@suaveplan/events";
type Map = {
"user.signup": { id: string; email: string };
"order.paid": { orderId: string; amount: number };
};
const bus = new EventBus<Map>();
bus.on("user.signup", ({ email }) => sendWelcome(email));
bus.emit("user.signup", { id: "u1", email: "alice@example.com" });
```
### Interceptor
```ts
bus.use(async (envelope, next) => {
const start = performance.now();
const result = await next();
histogram.record(performance.now() - start, { type: envelope.type });
return result;
});
```
### Backpressure
```ts
import { BoundedQueue } from "@suaveplan/events";
const q = new BoundedQueue<Order>({ capacity: 1000, policy: "block" });
await q.enqueue(order, signal);
```
### Idempotency
```ts
import { idempotent } from "@suaveplan/events";
bus.on(
"order.paid",
idempotent(handlePayment, { keyOf: (o) => o.orderId })
);
```
## Error codes
```text
EVENTS.LISTENER.THREW
EVENTS.INTERCEPTOR.THREW
EVENTS.QUEUE.OVERFLOW
EVENTS.QUEUE.ABORTED
EVENTS.QUEUE.INVALID_CAPACITY
EVENTS.EMIT.TIMED_OUT
EVENTS.EMIT.ABORTED
```
All surface as `EventsError extends SuaveplanError<EventsCode>` from `@suaveplan/error`.
## Foundation rules honoured
- Runtime deps: `@suaveplan/error`, `tslib`, `zod` (all catalog-pinned)
- Zero Node / browser built-ins in `src/` (guarded)
- `Date.now` / `crypto.randomUUID` live only in `clock/default-clock.ts` and `id/id-generator.ts` (guarded)
- `sideEffects: false`, per-module subpath exports
- 100% statement / branch / function / line coverage
## Testing
```bash
bunx turbo lint typecheck test --filter=@suaveplan/events
bunx turbo test:coverage --filter=@suaveplan/events
```

View File

@@ -0,0 +1,100 @@
# event-bus
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EventBus` is the typed publish/subscribe primitive at the heart of `@suaveplan/events`. It gives callers a strongly-typed `emit` / `on` API keyed by an event-name → payload map, deterministic FIFO delivery, a public interceptor pipeline for cross-cutting concerns, and an async variant that honours `AbortSignal` and timeouts. Downstream packages (`events-metrics`, `events-otel`, `events-schema`, `events-transport`) attach through the interceptor surface instead of monkey-patching internals.
## Features
- Typed via `EventBus<TMap extends Record<string, unknown>>` — payload inferred from event name
- `on`, `once`, `off`, `emit` (sync), `emitAsync` (async with signal + timeout), `use` (interceptor), `clear`
- Interceptor composition — first-registered interceptor is outermost
- Listener throws are captured into `EmitResult.errors`, never propagated (except via interceptor)
- Interceptor throws propagate wrapped as `EventsError("EVENTS.INTERCEPTOR.THREW")` unless already an `EventsError`
- Injectable clock + id generator per bus — zero non-determinism without injection
## Usage: Basic Example
```ts
import { EventBus } from "@suaveplan/events";
type Map = { "user.signup": { id: string; email: string } };
const bus = new EventBus<Map>();
bus.on("user.signup", ({ email }) => sendWelcome(email));
bus.emit("user.signup", { id: "u1", email: "alice@example.com" });
```
## Usage: Advanced Example
```ts
import { EventBus, EventsError, EVENTS_CODES } from "@suaveplan/events";
type Map = {
"order.paid": { orderId: string; amount: number };
"order.refunded": { orderId: string };
};
const bus = new EventBus<Map>({
clock: () => 0,
idGenerator: () => "id-test",
});
// Metric interceptor.
bus.use(async (envelope, next) => {
const start = performance.now();
try {
const r = await next();
histogram.record(performance.now() - start, {
type: envelope.type,
status: "ok",
});
return r;
} catch (err) {
histogram.record(performance.now() - start, {
type: envelope.type,
status: "error",
});
throw err;
}
});
bus.on("order.paid", async (o) => {
await ledger.record(o.orderId, o.amount);
});
const controller = new AbortController();
const result = await bus.emitAsync(
"order.paid",
{ orderId: "o-1", amount: 99 },
{ signal: controller.signal, timeoutMs: 5000, traceId: "trace-1" }
);
if (result.errors.length > 0) console.warn(result.errors);
```
## API Reference
### `class EventBus<TMap extends EventMap>`
- `constructor(options?: EventBusOptions)` — `options.clock`, `options.idGenerator` inject determinism
- `on(type, listener)` → `Unsubscribe`
- `once(type, listener)` → `Unsubscribe`
- `off(type, listener)` → `void`
- `emit(type, payload, traceId?)` → `EmitResult` _(synchronous — listener Promise returns are ignored)_
- `emitAsync(type, payload, options?)` → `Promise<EmitResult>` — honours `signal`, `timeoutMs`, `traceId`
- `use(interceptor)` → `Unsubscribe`
- `clear()` — removes all listeners and interceptors
### Error codes raised
- `EVENTS.LISTENER.THREW` (captured, not thrown)
- `EVENTS.INTERCEPTOR.THREW`
- `EVENTS.EMIT.ABORTED`
- `EVENTS.EMIT.TIMED_OUT`
## Implementation Notes
`emit` iterates a `slice()` of the listener array so an `off` inside a listener doesn't reshape the live iteration. Async dispatch checks `signal.aborted` before the first listener AND between listeners, so an abort inside a listener halts subsequent ones. Timeout is implemented with `setTimeout` + `clearTimeout`; the cleared path is exercised on the success branch to keep 100% coverage without relying on the timer firing. `emitAsync` pre-wraps non-`EventsError` throws; already-typed `EventsError` rethrows propagate unchanged so callers can distinguish interceptor policy failures from internal bugs.

View File

@@ -0,0 +1,51 @@
# default-clock
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`defaultClock` is the single location in `@suaveplan/events` that reads wall-clock time. Every other module receives a `Clock` injection; this file is the source that downstream defaults point to. Isolating `Date.now()` into one three-line function means a test can reassign the exported reference in one place and no other code path sneaks a non-deterministic read past the boundary.
## Features
- Thin wrapper over `Date.now()` — no extra work, no allocations
- `Clock` type alias exported for consumers who inject their own
- Guarded by the package's `tests/no-disallowed.test.ts` — any other file that calls `Date.now()` fails the build
## Usage: Basic Example
```ts
import { defaultClock } from "@suaveplan/events";
defaultClock(); // 1_700_000_000_000
```
## Usage: Advanced Example
```ts
import { EventBus, type Clock } from "@suaveplan/events";
// Testing a bus with a deterministic, monotonically-increasing clock.
let now = 0;
const clock: Clock = () => ++now;
const bus = new EventBus<{ tick: number }>({ clock });
bus.on("tick", (n) => console.log("at", now, "payload", n));
bus.emit("tick", 1); // envelope.timestamp === 1
bus.emit("tick", 2); // envelope.timestamp === 2
```
## API Reference
### `defaultClock(): number`
Returns `Date.now()`. The only module-scope `Date.now()` call in `src/`.
### `type Clock = () => number`
Interface every injection point in the package accepts.
## Implementation Notes
Keeping this function as a named export (rather than inlining `Date.now` at each callsite) is what lets `tests/no-disallowed.test.ts` grep for `Date.now` across `src/` and assert there are zero matches outside this file. That negative-space guard is how the package stays deterministic as it grows — any future module that needs time must either take a `Clock` injection or import `defaultClock` from here. Performance is indistinguishable from a bare `Date.now()` call: V8 inlines trivial pass-through functions.

View File

@@ -0,0 +1,80 @@
# envelope
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`wrap` turns a `(type, payload)` pair into a complete `EventEnvelope` with a unique `id`, a deterministic `timestamp`, and an optional `traceId`. Envelopes are the contract between the bus, interceptors, and transport/persistence adapters — giving every event a stable identity lets downstream adapters dedupe, correlate, and replay without adding their own bookkeeping.
## Features
- Accepts injectable `clock` and `idGenerator` so tests are deterministic
- Honours caller-provided `id` and `timestamp` when supplied — useful for replaying or forwarding existing events
- Omits `traceId` entirely when unset (respects `exactOptionalPropertyTypes`)
- Pure — no module-scope non-determinism beyond the two injected defaults
- Type-generic — payload type flows through to the returned envelope
## Usage: Basic Example
```ts
import { wrap } from "@suaveplan/events";
const envelope = wrap("user.signup", { email: "alice@example.com" });
// { id: "...", type: "user.signup", payload: {...}, timestamp: 1700000000000 }
```
## Usage: Advanced Example
```ts
import { wrap } from "@suaveplan/events";
// Deterministic envelope for a replay pipeline.
function replay(type: string, payload: unknown, seed: number): EventEnvelope {
return wrap(
type,
payload,
{ traceId: `replay-${seed}` },
{
clock: () => seed,
idGenerator: () => `replay-${seed}`,
}
);
}
// Forward an existing envelope with a new id but the original timestamp.
const forwarded = wrap(
existing.type,
existing.payload,
{ timestamp: existing.timestamp },
);
```
## API Reference
### `wrap<TPayload>(type, payload, options?, deps?): EventEnvelope<TPayload>`
- `type` — event name; stored on the envelope
- `payload` — opaque to this function
- `options.id?` — force a specific id; otherwise `deps.idGenerator()` is called
- `options.timestamp?` — force a specific timestamp; otherwise `deps.clock()` is called
- `options.traceId?` — attached when present, omitted when absent
- `deps.clock?`, `deps.idGenerator?` — defaults are `defaultClock` / `defaultIdGenerator`
### `interface EventEnvelope<TPayload = unknown>`
- `readonly id: string`
- `readonly type: string`
- `readonly payload: TPayload`
- `readonly timestamp: number`
- `readonly traceId?: string`
### `interface WrapOptions`
- `readonly id?: string`
- `readonly timestamp?: number`
- `readonly traceId?: string`
## Implementation Notes
`traceId` is conditionally assigned so the final envelope has a `traceId` property only when one was provided — `undefined` never sneaks in, which matters under `exactOptionalPropertyTypes`. `clock` and `idGenerator` are resolved lazily per call; the module imports the `default*` values but only invokes them when the caller hasn't overridden. This keeps the envelope construction pure under test (inject mocks once) and cheap at runtime (no function-creation overhead in the hot path).

View File

@@ -0,0 +1,56 @@
# codes
## Purpose
`EVENTS_CODES` is the frozen registry of every failure code `@suaveplan/events` can raise. All codes share the `EVENTS.` namespace and are built through `defineCodes` from `@suaveplan/error`, so they're literal-typed, frozen at runtime, and safe to use in exhaustive `switch` statements. Consumers match on them to distinguish listener failures from interceptor failures from queue overflows from emit-lifecycle failures.
## Features
- Frozen record keyed by reason, values prefixed `EVENTS.`
- `EventsCode` type alias for the discriminated union
- Stable — new codes append without renumbering existing ones
- Matches the shape of every other package's registry for uniform tooling
## Usage: Basic Example
```ts
import { EVENTS_CODES } from "@suaveplan/events";
EVENTS_CODES["EMIT.ABORTED"]; // "EVENTS.EMIT.ABORTED"
```
## Usage: Advanced Example
```ts
import { EVENTS_CODES, EventsError, type EventsCode } from "@suaveplan/events";
function classify(err: EventsError): "retry" | "drop" | "alert" {
switch (err.code) {
case "EVENTS.LISTENER.THREW":
case "EVENTS.INTERCEPTOR.THREW":
return "alert";
case "EVENTS.EMIT.TIMED_OUT":
case "EVENTS.QUEUE.OVERFLOW":
return "retry";
case "EVENTS.EMIT.ABORTED":
case "EVENTS.QUEUE.ABORTED":
return "drop";
case "EVENTS.QUEUE.INVALID_CAPACITY":
return "alert";
}
}
```
## API Reference
### `EVENTS_CODES`
Frozen record keyed by reason (e.g. `"LISTENER.THREW"`), values prefixed `"EVENTS."`.
### `type EventsCode`
Union: `"EVENTS.LISTENER.THREW"` | `"EVENTS.INTERCEPTOR.THREW"` | `"EVENTS.QUEUE.OVERFLOW"` | `"EVENTS.QUEUE.ABORTED"` | `"EVENTS.QUEUE.INVALID_CAPACITY"` | `"EVENTS.EMIT.TIMED_OUT"` | `"EVENTS.EMIT.ABORTED"`.
## Implementation Notes
Codes are never renumbered — new failure modes append to the tuple, which keeps downstream consumer `switch` blocks forward-compatible (adding a new code surfaces as a TypeScript error on exhaustive checks, which is the intended review prompt). The registry is intentionally flat; sub-domains use dotted reasons (`"QUEUE.OVERFLOW"`) rather than nested objects so `ERROR_CODES[reason]` is always a direct string lookup.

View File

@@ -0,0 +1,57 @@
# events-error
## Purpose
`EventsError` is the single concrete error class thrown or captured by `@suaveplan/events`. It extends `SuaveplanError` from `@suaveplan/error` and narrows the `code` union to `EventsCode` (the literal union of every `EVENTS.*` value in `EVENTS_CODES`). Having one class per package means consumers can `instanceof EventsError` to catch everything this library raises, and then switch on `err.code` for precise handling.
## Features
- `code` narrowed to `EventsCode` — exhaustive switch checks for free
- Public constructor that widens the protected base constructor
- Carries `cause`, `context`, `timestamp`, `traceId` via the base class
- Interop-ready — `toJSON` from `@suaveplan/error` already knows how to serialise it
## Usage: Basic Example
```ts
import { EventsError, EVENTS_CODES } from "@suaveplan/events";
throw new EventsError(
EVENTS_CODES["EMIT.ABORTED"],
"error.events.emit.aborted"
);
```
## Usage: Advanced Example
```ts
import { EventBus, EventsError, EVENTS_CODES } from "@suaveplan/events";
const bus = new EventBus<{ ping: null }>();
bus.on("ping", () => {
throw new Error("listener boom");
});
const result = bus.emit("ping", null);
for (const err of result.errors) {
if (err instanceof EventsError && err.code === EVENTS_CODES["LISTENER.THREW"]) {
console.warn(
"listener failed",
err.context?.type,
err.context?.envelopeId,
err.cause
);
}
}
```
## API Reference
### `class EventsError extends SuaveplanError<EventsCode>`
- `new EventsError(code, messageKey, options?)` — same signature as `SuaveplanError`
- `err.code: EventsCode` — one of the literal strings in `EVENTS_CODES`
## Implementation Notes
The public constructor is a pass-through — it exists only to widen the base's `protected` constructor, and a Biome suppression is in place because the pattern is intentional for access-modifier widening. The class never constructs its own `cause` or `context`; those are always supplied by the caller. The bus always constructs `EventsError` instances with `context` that includes `{ type, envelopeId }` for listener-throw captures and `{ timeoutMs }` or `{ capacity }` for queue-related failures — consumers can rely on those keys being present for each code.

View File

@@ -0,0 +1,56 @@
# id-generator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`defaultIdGenerator` is the single location in `@suaveplan/events` that reads non-deterministic randomness. It produces a UUID per call via `crypto.randomUUID()` and is the default source of envelope `id`s. Like `defaultClock`, isolating randomness into one function lets the package's no-disallowed test grep the rest of `src/` for stray `Math.random` / `crypto.randomUUID` calls and fail the build if they leak.
## Features
- Uses the Web Crypto `crypto.randomUUID()` — available in every supported runtime (Bun, Node ≥ 19, modern browsers)
- Returns an RFC 4122 v4 UUID string
- `IdGenerator` type alias exported for consumers who inject their own
- Pure — no state, no side effects
## Usage: Basic Example
```ts
import { defaultIdGenerator } from "@suaveplan/events";
defaultIdGenerator(); // "c2c2ab36-..."
```
## Usage: Advanced Example
```ts
import { EventBus, type IdGenerator } from "@suaveplan/events";
// Testing with a deterministic counter-based id generator.
let n = 0;
const idGenerator: IdGenerator = () => `id-${++n}`;
const bus = new EventBus<{ ping: null }>({ idGenerator });
bus.on("ping", (_, envelope) => console.log(envelope.id));
bus.emit("ping", null); // envelope.id === "id-1"
// Production with a prefixed scheme.
const tenantBus = new EventBus<{ ping: null }>({
idGenerator: () => `tenant-42:${crypto.randomUUID()}`,
});
```
## API Reference
### `defaultIdGenerator(): string`
Returns `crypto.randomUUID()`. The only module-scope `crypto.randomUUID()` call in `src/`.
### `type IdGenerator = () => string`
Interface every injection point in the package accepts.
## Implementation Notes
`crypto.randomUUID()` is preferred over `Math.random` because it's both genuinely random and available in every supported runtime. In Node < 19 and older test harnesses the `crypto` global may be absent — the package's `tests/setup/polyfills.ts` installs `webcrypto` as `globalThis.crypto` to cover that case. Like `defaultClock`, keeping this as a named export is what lets the guard test grep for `crypto.randomUUID` across `src/` and assert exactly one callsite. Collisions are cryptographically negligible, so envelope ids are safe to use as dedup keys.

View File

@@ -0,0 +1,76 @@
# idempotent
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`idempotent` wraps an event listener so it fires at most once per `keyOf(payload)`. It's the standard pattern for at-least-once delivery scenarios where the transport may redeliver the same event and the listener must be safe against that. The wrapper is cache-agnostic — the default is an unbounded `Set`, but any object conforming to the two-method `IdempotencyCache` interface (LRU, Redis-backed, SQLite, etc.) plugs in without changes to the listener.
## Features
- Pure closure wrapper — no module-scope state
- Default in-memory `Set` cache — sufficient for short-lived processes
- Pluggable cache via the `IdempotencyCache` interface (`has`, `add`)
- Listener return type preserved (`void | Promise<void>`)
- Key derivation is caller-provided — nothing assumed about payload shape
## Usage: Basic Example
```ts
import { EventBus, idempotent } from "@suaveplan/events";
const bus = new EventBus<{ paid: { orderId: string } }>();
bus.on(
"paid",
idempotent((o) => ledger.record(o.orderId), { keyOf: (o) => o.orderId })
);
```
## Usage: Advanced Example
```ts
import { EventBus, idempotent, type IdempotencyCache } from "@suaveplan/events";
import { LRUCache } from "lru-cache";
class LRUIdempotency implements IdempotencyCache {
private readonly cache = new LRUCache<string, true>({ max: 10_000 });
public has(key: string): boolean {
return this.cache.has(key);
}
public add(key: string): void {
this.cache.set(key, true);
}
}
const bus = new EventBus<{ "email.send": { requestId: string; to: string } }>();
bus.on(
"email.send",
idempotent(
async ({ to }) => await mail.send(to),
{ keyOf: (p) => p.requestId, cache: new LRUIdempotency() }
)
);
```
## API Reference
### `idempotent<TPayload>(listener, options): Listener<TPayload>`
- `listener` — the underlying `(payload: TPayload) => void | Promise<void>`
- `options.keyOf` — derives the dedup key from the payload
- `options.cache?` — optional backing store; defaults to an in-memory `Set`-backed impl
### `interface IdempotencyCache`
- `has(key: string): boolean`
- `add(key: string): void`
### `interface IdempotentOptions<TPayload>`
- `readonly keyOf: (payload: TPayload) => string`
- `readonly cache?: IdempotencyCache`
## Implementation Notes
The cache is checked before the listener runs; on hit, the wrapper returns `undefined` and does NOT call the listener. `add` happens before the listener runs so that two concurrent invocations with the same key (within the same event loop tick) still see only one dispatch. For distributed idempotency, back the cache with a Redis `SETNX` — the interface is deliberately minimal so any such adapter drops in. The default cache is unbounded, which is fine for small/short-lived processes but NOT for long-running services; pass an LRU explicitly in that case.

View File

@@ -0,0 +1,89 @@
# compose
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`compose` folds a list of interceptors into a single runnable async function, wiring the chain-of-responsibility that `EventBus.emitAsync` drives. It's factored out of the bus so consumers can build their own interceptor-aware dispatchers (e.g. a custom queue processor) without reimplementing the composition rules. First-registered interceptors are outermost; each interceptor decides whether to call `next()` (advance), transform the envelope (replace and advance), or return without calling it (short-circuit the dispatch).
## Features
- Pure function returning a bound runner — no module-scope state
- First-registered interceptor is outermost; last-registered is innermost
- Interceptors MAY call `next()` zero times (drop), one time (continue), or observe the result (tap)
- Returns the `Promise<EmitResult>` produced by the terminal dispatcher unchanged
- Terminal function is injected — `compose` is bus-agnostic
## Usage: Basic Example
```ts
import { compose, type Interceptor } from "@suaveplan/events";
const log: Interceptor = async (envelope, next) => {
console.log("before", envelope.type);
const r = await next();
console.log("after", envelope.type);
return r;
};
const run = compose([log], async () => ({ delivered: 0, errors: [] }));
await run({ id: "1", type: "demo", payload: null, timestamp: 0 });
```
## Usage: Advanced Example
```ts
import { compose, type EmitResult, type Interceptor } from "@suaveplan/events";
const trace: string[] = [];
const outer: Interceptor = async (_, next) => {
trace.push("outer-pre");
const r = await next();
trace.push("outer-post");
return r;
};
const inner: Interceptor = async (_, next) => {
trace.push("inner-pre");
const r = await next();
trace.push("inner-post");
return r;
};
const dropIfAuthenticated: Interceptor = async (envelope, next) =>
envelope.type === "auth.token" && (envelope.payload as { auth: boolean }).auth
? ({ delivered: 0, errors: [] } satisfies EmitResult)
: next();
const run = compose(
[outer, dropIfAuthenticated, inner],
async () => ({ delivered: 1, errors: [] })
);
```
## API Reference
### `compose(interceptors, terminal)`
- `interceptors: readonly Interceptor[]` — ordered chain
- `terminal: (envelope: EventEnvelope) => Promise<EmitResult>` — innermost function, runs after every interceptor calls `next()`
- Returns `(envelope: EventEnvelope) => Promise<EmitResult>`
### `type Interceptor`
```ts
(envelope: EventEnvelope, next: () => Promise<EmitResult>) => Promise<EmitResult>
```
### `type Next = () => Promise<EmitResult>`
### `interface EmitResult`
- `readonly delivered: number`
- `readonly errors: readonly unknown[]`
## Implementation Notes
Composition uses an inner `run(index, env)` helper that's tail-recursive in the happy path; each recursive call's closure is GC'd as soon as `next()` resolves. The `envelope` handed to each interceptor is the same one the outer caller passed — interceptors that want to transform MUST rebuild a new envelope and pass it to `next()` (which they can, because `next` closes over `run` which accepts an `env` argument). The empty-interceptor case is handled by the `index >= length` check and falls through to `terminal(env)` directly — no special empty-array branch needed.

View File

@@ -0,0 +1,76 @@
# with-observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`withObservability` installs a passive observability adapter onto an `EventBus` as an interceptor. The adapter receives hooks before dispatch, on successful completion, and on rejection — without importing any OpenTelemetry SDK or metrics framework. It's the contract that downstream adapters (`events-metrics`, `events-otel`, Prometheus exporters, etc.) plug into.
## Features
- One-call integration — pass a bus and an adapter, receive an unsubscribe function
- Three hooks — `onEmit` (pre), `onEmitComplete` (post-success), `onEmitError` (post-failure)
- All hooks are optional — adapters only supply what they need
- No OpenTelemetry SDK imported — adapter shape is intentionally framework-free
- Uses the existing interceptor API — no private bus internals
## Usage: Basic Example
```ts
import { EventBus, withObservability } from "@suaveplan/events";
const bus = new EventBus<{ ping: null }>();
withObservability(bus, {
onEmit: (env) => console.log("emit", env.type),
onEmitComplete: (env, result) =>
console.log("done", env.type, result.delivered),
});
```
## Usage: Advanced Example
```ts
import { EventBus, withObservability } from "@suaveplan/events";
import { trace, metrics } from "@opentelemetry/api";
const tracer = trace.getTracer("events");
const latency = metrics.getMeter("events").createHistogram("events.latency_ms");
const bus = new EventBus<{ paid: { orderId: string } }>();
const unsub = withObservability(bus, {
onEmit: (env) => {
const span = tracer.startSpan(`emit.${env.type}`);
(env as unknown as { __span?: unknown }).__span = span;
},
onEmitComplete: (env, result) => {
const span = (env as unknown as { __span?: { end: () => void } }).__span;
span?.end();
latency.record(Date.now() - env.timestamp, { type: env.type, status: "ok" });
},
onEmitError: (env, err) => {
latency.record(Date.now() - env.timestamp, { type: env.type, status: "error" });
},
});
// Later:
unsub();
```
## API Reference
### `withObservability<TMap>(bus, adapter): () => void`
- `bus: EventBus<TMap>` — the bus to wrap
- `adapter: ObservabilityAdapter` — hooks object
- Returns an unsubscribe function that removes the installed interceptor
### `interface ObservabilityAdapter`
- `onEmit?(envelope): void` — before listeners + inner interceptors
- `onEmitComplete?(envelope, result): void` — after successful dispatch
- `onEmitError?(envelope, error): void` — after a rejection
## Implementation Notes
The hooks fire synchronously around the async `next()` call — their runtime cost is whatever the adapter does, with no overhead from the wrapper itself. Because this is an interceptor, its placement in the interceptor chain matters: first-registered is outermost, so calling `withObservability` before any other `bus.use` means the adapter wraps every other interceptor's time. This is usually what you want (you measure the full dispatch including downstream interceptors). If an adapter hook throws, the throw propagates — adapters are expected to be exception-free; wrap their internals in try/catch if that's not guaranteed.

View File

@@ -0,0 +1,87 @@
# bounded-queue
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BoundedQueue` is a FIFO queue with a hard capacity and a caller-chosen overflow policy (`drop-oldest`, `drop-newest`, `block`, `throw`). It's the backpressure primitive exposed by `@suaveplan/events` so consumers can apply uniform queueing rules to whatever produces faster than their downstream can consume — bus dispatch, transport forwarding, log batching, etc. `AbortSignal` support on `enqueue` makes it cooperative with cancellation-aware pipelines.
## Features
- Four overflow policies — chosen at construction, never at enqueue time
- `enqueue` returns a `Promise<void>` regardless of policy so call sites stay uniform
- `block` waits for room; resolves when a later `dequeue` makes space
- `throw` rejects with `EventsError("EVENTS.QUEUE.OVERFLOW")`
- `drop-oldest` evicts the head; `drop-newest` silently drops the new item
- `AbortSignal` — aborts a pending `block` with `EVENTS.QUEUE.ABORTED`
- `drain()` snapshots items + resolves blocked waiters where capacity permits
## Usage: Basic Example
```ts
import { BoundedQueue } from "@suaveplan/events";
const q = new BoundedQueue<string>({ capacity: 2, policy: "drop-oldest" });
await q.enqueue("a");
await q.enqueue("b");
await q.enqueue("c"); // "a" evicted
q.dequeue(); // "b"
q.dequeue(); // "c"
```
## Usage: Advanced Example
```ts
import { BoundedQueue, EventsError, EVENTS_CODES } from "@suaveplan/events";
const q = new BoundedQueue<Order>({ capacity: 1000, policy: "block" });
const ctrl = new AbortController();
async function producer(stream: AsyncIterable<Order>): Promise<void> {
for await (const order of stream) {
try {
await q.enqueue(order, ctrl.signal);
} catch (err) {
if (
err instanceof EventsError &&
err.code === EVENTS_CODES["QUEUE.ABORTED"]
) return;
throw err;
}
}
}
async function consumer(): Promise<void> {
// Pull one item at a time, respecting downstream processing time.
while (!ctrl.signal.aborted) {
const order = q.dequeue();
if (order !== undefined) await process(order);
else await new Promise((r) => setTimeout(r, 50));
}
}
```
## API Reference
### `class BoundedQueue<T>`
- `constructor(options: BoundedQueueOptions)` — `capacity` must be a positive integer; non-integer or ≤0 raises `EventsError("EVENTS.QUEUE.INVALID_CAPACITY")`
- `size: number` — current item count
- `pendingWaiters: number` — count of enqueues blocked on capacity (policy `block`)
- `enqueue(item, signal?): Promise<void>` — policy-dependent
- `dequeue(): T | undefined` — pops the head, resolves one waiter if any
- `drain(): T[]` — removes all items; resolves pending waiters where capacity permits; returns the drained snapshot
### `interface BoundedQueueOptions`
- `readonly capacity: number` — positive integer
- `readonly policy: "drop-oldest" | "drop-newest" | "block" | "throw"`
### `type QueuePolicy`
Union of the four policy strings.
## Implementation Notes
`block` waiters are tracked in a separate array; `dequeue` and `drain` call `drainOneWaiter()` to pull the next pending item into the freed slot. `drain()` stops draining waiters once capacity is filled — without the `items.length < capacity` guard the loop would spin forever when waiters outnumber the slots, which was a real bug caught during testing. Abort handling attaches a `once: true` listener to the signal; if the waiter resolves first, `detach()` removes the listener so late aborts don't fire stale rejections. Non-integer capacity is caught via `Number.isInteger` rather than `typeof` so fractional values like `1.5` fail fast.