Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
plugins/document-navigator/docs/networking/events/README.md
Normal file
19
plugins/document-navigator/docs/networking/events/README.md
Normal 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 -->
|
||||
@@ -0,0 +1 @@
|
||||
Typed event bus, event sourcing, metrics, and transport bridges.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user