Files
suaveplan/openspec/archive/2026-04-19-enterprise-events/proposal.md
2026-05-27 23:29:53 +01:00

60 lines
3.7 KiB
Markdown

# Change: enterprise-events
## Why
`@suaveplan/events` is the foundation pub/sub substrate — typed event bus, deterministic ordering, backpressure-aware async dispatch, and a public companion/interceptor API that the sibling packages (`events-metrics`, `events-otel`, `events-schema`, `events-transport`) plug into without monkey-patching. Every domain-event-emitting package will depend on it.
Per the foundation-100-coverage rule (see memory `foundation-100-coverage`), this ships at **100%** coverage from day one.
## What Changes
### New package: `packages/universal/events`
Ships as a focused, zero-Node / zero-browser / zero-React TypeScript library. Runtime deps limited to `@suaveplan/error`, `tslib`, and `zod`.
#### Core surface
- **`EventBus<TMap>`** — typed bus parameterized by an event-name → payload map. Methods: `on`, `once`, `off`, `emit` (sync), `emitAsync` (async with AbortSignal), `use` (interceptor), `clear`
- **`EventEnvelope<T>`** — `{ id, type, payload, timestamp, traceId? }`, produced by `wrap(type, payload, options?)`
- **`createIdGenerator()`** — deterministic-under-injection id generator (default: `crypto.randomUUID()` via a single module-local reference that tests can swap)
- **`Interceptor<TMap>`** — `(envelope, next) => R | Promise<R>` chain-of-responsibility. Registration order defines execution order; `next()` advances the chain; interceptors can replace, drop (by not calling `next`), or observe
- **`BoundedQueue<T>`** — in-memory FIFO with `{ capacity, policy: "drop-oldest" | "drop-newest" | "block" | "throw" }`, used internally by `emitAsync` under backpressure and exposed for consumers
- **`idempotent(fn, { keyOf, cache? })`** — wraps a handler so a given `keyOf(envelope)` only fires once; the cache is a `Map` by default, pluggable
#### Error surface
- **`EventsError`** extends `SuaveplanError<EventsCode>` with codes like `EVENTS.LISTENER.THREW`, `EVENTS.QUEUE.OVERFLOW`, `EVENTS.QUEUE.ABORTED`, `EVENTS.SCHEMA.REJECTED`, `EVENTS.INTERCEPTOR.THREW`
- Errors capture `{ type, envelopeId, listenerIndex?, queueSize? }` in `context` and preserve the original `cause`
#### Schemas
- **`EventEnvelopeSchemas`** (`.schemas.ts`) — Zod schema for the envelope shape with generic payload; consumers opt in by importing `@suaveplan/events/envelope.schemas`
#### Observability hook
- **`withObservability(bus, adapter)`** — wraps an `EventBus` with a passive adapter implementing `{ onEmit?, onEmitError?, onEmitComplete?, onListenerThrew? }`; `events-metrics` / `events-otel` register here
#### Companion/interceptor API
- `bus.use(interceptor)` is the one public extension point; `events-schema` validates envelopes via an interceptor, `events-transport` forwards to an external sink via an interceptor, `events-metrics` times via an interceptor, `events-otel` creates spans via an interceptor. The core ships no direct coupling to any of those.
### Determinism
- No `Math.random()` / `Date.now()` at module scope
- `createBus({ clock, idGenerator })` accepts injections; defaults are the thin wrappers `defaultClock = () => Date.now()` and `defaultIdGenerator = () => crypto.randomUUID()`
### Coverage
Vitest thresholds locked at 100/100/100/100 in `vitest.config.ts`. Property-based tests (fast-check) cover:
- dispatch ordering (FIFO across listeners and across emits)
- interceptor composition (associativity, drop-propagation)
- backpressure policies (bounded queue invariants)
- idempotency (given key-collisions, handler called exactly once)
## Impact
- Unblocks `events-metrics`, `events-otel`, `events-schema`, `events-transport` (each a Layer 2 adapter on top of `@suaveplan/events`)
- Depends on `@suaveplan/error` (already published) for `SuaveplanError` and `defineCodes`
- No existing genesis consumers affected