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

3.7 KiB

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