# Design: enterprise-events ## Module layout ```text packages/universal/events/ ├── package.json # deps: @suaveplan/error, tslib, zod ├── tsconfig.lib.json ├── vite.config.ts ├── vitest.config.ts # thresholds 100/100/100/100 ├── README.md ├── src/ │ ├── index.ts # barrel │ ├── constants/index.ts │ ├── errors/ │ │ ├── codes.ts │ │ ├── events-error.ts │ │ └── events-error.test.ts │ ├── envelope/ │ │ ├── envelope.ts # wrap(type, payload, options) │ │ ├── envelope.md │ │ ├── envelope.test.ts │ │ ├── envelope.types.ts │ │ └── envelope.schemas.ts # Zod (opt-in) │ ├── id/ │ │ ├── id-generator.ts │ │ ├── id-generator.md │ │ └── id-generator.test.ts │ ├── clock/ │ │ ├── default-clock.ts │ │ ├── default-clock.md │ │ └── default-clock.test.ts │ ├── bus/ │ │ ├── event-bus.ts # EventBus class │ │ ├── event-bus.md │ │ ├── event-bus.test.ts │ │ └── event-bus.types.ts │ ├── interceptor/ │ │ ├── compose.ts # compose interceptors into a chain │ │ ├── compose.md │ │ └── compose.test.ts │ ├── queue/ │ │ ├── bounded-queue.ts │ │ ├── bounded-queue.md │ │ └── bounded-queue.test.ts │ ├── idempotent/ │ │ ├── idempotent.ts │ │ ├── idempotent.md │ │ └── idempotent.test.ts │ ├── observability/ │ │ ├── with-observability.ts │ │ ├── with-observability.md │ │ └── with-observability.test.ts │ └── __benchmarks__/ │ └── dispatch.bench.ts ├── tests/ │ ├── setup/polyfills.ts │ ├── dep-graph.test.ts │ ├── no-disallowed.test.ts │ └── tree-shake.test.ts └── CHANGELOG.md ``` ## Key design decisions ### Typed `EventBus` ```ts type EventMap = Record; class EventBus { on(type: K, listener: Listener): Unsubscribe; once(type: K, listener: Listener): Unsubscribe; off(type: K, listener: Listener): void; emit(type: K, payload: TMap[K]): EmitResult; emitAsync( type: K, payload: TMap[K], options?: { signal?: AbortSignal; timeoutMs?: number } ): Promise; use(interceptor: Interceptor): Unsubscribe; clear(): void; } ``` `EmitResult`: `{ delivered: number; errors: readonly EventsError[] }`. Sync `emit` collects listener errors; async collects + surfaces as `errors[]` (never throws mid-dispatch; interceptors CAN throw to halt). ### Interceptor chain Interceptors run in registration order; last-registered is innermost. Signature: ```ts type Interceptor = ( envelope: EventEnvelope, next: () => Promise ) => Promise; ``` The bus composes them with `compose(interceptors)` into a single function. Calling `next()` advances; not calling it drops the event (no listeners fire). Throwing aborts the emit and propagates the rejection. ### BoundedQueue Independent of the bus — used internally when `emitAsync` is called with `{ queue: true }` and externally by consumers who want the same backpressure primitive. ```ts interface BoundedQueueOptions { capacity: number; policy: "drop-oldest" | "drop-newest" | "block" | "throw"; } class BoundedQueue { enqueue(item: T, signal?: AbortSignal): Promise; dequeue(): T | undefined; drain(): T[]; get size(): number; } ``` `block` waits for room (Promise resolves when dequeued). `drop-oldest` evicts the head. `drop-newest` rejects the new enqueue silently. `throw` throws `EventsError("EVENTS.QUEUE.OVERFLOW")`. `enqueue` with a signal that aborts rejects with `EventsError("EVENTS.QUEUE.ABORTED")`. ### `idempotent` Wraps a listener so a given key only fires once. Default cache is a `Map`; consumers can pass `{ cache: LRUCache }` to bound growth. ```ts function idempotent( listener: Listener, options: { keyOf: (payload: T) => string; cache?: IdempotencyCache } ): Listener; ``` ### `withObservability` Not an interceptor — a passive adapter that receives hooks. Counters/histograms/spans live in `events-metrics` / `events-otel`; this package exposes only the hook shape. ### Determinism Two single-location module references: ```ts // clock/default-clock.ts export const defaultClock = (): number => Date.now(); // id/id-generator.ts export const defaultIdGenerator = (): string => crypto.randomUUID(); ``` Every other file accepts an injection. The two defaults are the only places `Date.now()` / `crypto.randomUUID()` appear in `src/`. ### Error codes ```ts export const EVENTS_CODES = defineCodes("EVENTS", [ "LISTENER.THREW", "INTERCEPTOR.THREW", "QUEUE.OVERFLOW", "QUEUE.ABORTED", "QUEUE.INVALID_CAPACITY", "SCHEMA.REJECTED", "EMIT.TIMED_OUT", "EMIT.ABORTED", ] as const); ``` ## Coverage strategy - Enumeration tests for every branch - Property-based (fast-check) for: ordering invariants, interceptor composition (associativity), queue policy state machines, idempotency - No `istanbul ignore` pragmas — defensive branches removed or rewritten as `as` assertions with invariant comments - Module-level determinism enforced by `tests/no-disallowed.test.ts` (greps for `Math.random` / `Date.now` / `crypto.randomUUID` outside the two approved files) ## Non-goals - Not shipping an OTel SDK — `withObservability` produces the hook shape; `events-otel` will implement the adapter - Not shipping a transport — `events-transport` plugs in as an interceptor - Not shipping metrics — `events-metrics` plugs in via `withObservability` - Not shipping a schema registry — `events-schema` plugs in as an interceptor