19 KiB
events
Purpose
Typed event-bus substrate for the Suaveplan monorepo — pub/sub with deterministic FIFO delivery, interceptor API, bounded-queue backpressure, idempotent listener wrappers, Zod-validated envelopes, and a passive observability adapter. Supports buffering for late subscribers, wildcard pattern matching, async iteration, concurrency policies (sequential/parallel/allSettled), and AbortSignal/timeout controls on async dispatch.
Requirements
Requirement: Zero-framework runtime dependencies
The @suaveplan/events package's runtime dependencies MUST be limited to @suaveplan/error, tslib, and zod. No peerDependencies; no other @suaveplan/* packages at runtime.
Verified by: tests/dep-graph.test.ts and tests/no-disallowed.test.ts.
Scenario: Dep-graph guard
- WHEN
dependenciesis read frompackage.json - THEN the set equals
{ @suaveplan/error, tslib, zod }
Scenario: No Node / browser built-ins leak
- WHEN
src/is scanned fornode:*,process,Buffer,document,window - THEN zero matches appear outside an approved defaults file
Requirement: Typed event bus
The package SHALL export an EventBus<TMap> class where TMap extends Record<string, unknown>. on, once, off, emit, and emitAsync are constrained by TMap so payload is inferred from type.
Verified by: src/bus/event-bus.test.ts (typed bus scenarios).
Scenario: Payload type narrows by event name
- WHEN a consumer declares
new EventBus<{ login: { user: string } }>()and callsbus.emit("login", x) - THEN
xis typed{ user: string }(notunknown)
Scenario: Unknown event name is a type error
- WHEN a consumer calls
bus.emit("does-not-exist", ...)on a bus with no such key - THEN TypeScript rejects at compile time
Requirement: Deterministic FIFO delivery
Listeners registered for a given event SHALL fire in registration order. Across events, synchronous emit delivers fully before returning; async emitAsync awaits every listener in registration order by default.
Verified by: src/bus/event-bus.test.ts (sync + async ordering scenarios).
Scenario: Sync order
- WHEN listeners A, B, C are registered on
"x"andemit("x", v)runs - THEN A runs to completion, then B, then C
Scenario: Async order
- WHEN async listeners A, B are registered on
"x"andemitAsync("x", v)runs - THEN A's returned promise settles before B is invoked
Requirement: Interceptor API
The package SHALL export bus.use(interceptor) registering an interceptor of shape (envelope, next) => Promise<EmitResult>. Interceptors compose in registration order (first-registered outermost). An interceptor MAY omit calling next() to drop the event without invoking listeners.
Verified by: src/interceptor/compose.test.ts and src/bus/event-bus.test.ts (interceptor scenarios).
Scenario: Interceptor can short-circuit
- WHEN an interceptor returns without calling
next() - THEN registered listeners do not fire AND
EmitResult.delivered === 0
Scenario: Interceptor throw aborts emit
- WHEN an interceptor throws
- THEN
emitAsyncrejects withEventsError("EVENTS.INTERCEPTOR.THREW")withcause === the thrown value
Scenario: Interceptor can replace payload
- WHEN an interceptor passes a new envelope to
next() - THEN downstream listeners observe the replaced payload
Requirement: Structured errors
All thrown errors SHALL be EventsError extends SuaveplanError<EventsCode> with codes prefixed EVENTS.. Listener failures SHALL be collected into EmitResult.errors, not propagated, unless an interceptor explicitly re-throws. This applies to both emit (sync) and emitAsync (async) — both paths capture listener throws into EmitResult.errors rather than rejecting the dispatch.
Verified by: src/errors/events-error.test.ts, src/bus/event-bus.test.ts, and src/conformance.spec-conformance.test.ts (sync + async listener-throw scenarios).
Scenario: Sync listener throw is captured
- WHEN a sync listener throws on
emit - THEN
emitreturnsEmitResultwitherrors[0].code === "EVENTS.LISTENER.THREW"ANDerrors[0].cause === the thrown valueANDdeliveredreflects successful deliveries only
Scenario: Async listener throw is captured
- WHEN an async listener rejects on
emitAsync - THEN the returned
Promise<EmitResult>resolves (does not reject) ANDerrors[0].code === "EVENTS.LISTENER.THREW"ANDerrors[0].cause === the rejection valueANDdeliveredreflects successful deliveries only
Requirement: Backpressure via BoundedQueue
The package SHALL export a BoundedQueue<T> class with { capacity, policy } where policy is one of "drop-oldest", "drop-newest", "block", "throw". enqueue(item, signal?) honours AbortSignal.
Verified by: src/queue/bounded-queue.test.ts.
Scenario: drop-oldest evicts head on overflow
- WHEN a 2-capacity queue with
"drop-oldest"receives 3 items - THEN only the latest 2 remain, head was evicted
Scenario: throw policy raises EVENTS.QUEUE.OVERFLOW
- WHEN a 1-capacity queue with
"throw"receives a 2nd enqueue - THEN the 2nd enqueue throws
EventsError("EVENTS.QUEUE.OVERFLOW")
Scenario: block waits for capacity
- WHEN a 1-capacity queue with
"block"is full AND a 2nd enqueue is pending AND the head is dequeued - THEN the pending enqueue resolves
Scenario: Aborted enqueue rejects
- WHEN
enqueue(x, signal)is pending under"block"AND the signal aborts - THEN the pending enqueue rejects with
EventsError("EVENTS.QUEUE.ABORTED")
Scenario: Invalid capacity rejected
- WHEN a queue is constructed with
capacity <= 0or non-integer capacity - THEN construction throws
EventsError("EVENTS.QUEUE.INVALID_CAPACITY")
Requirement: Idempotent listener wrapper
The package SHALL export idempotent(listener, { keyOf, cache? }) returning a listener that fires at most once per keyOf(payload).
Verified by: src/idempotent/idempotent.test.ts.
Scenario: Duplicate key suppressed
- WHEN the wrapped listener is invoked with two payloads yielding the same key
- THEN the underlying listener fires exactly once
Scenario: Distinct keys fire independently
- WHEN the wrapped listener is invoked with payloads yielding different keys
- THEN the underlying listener fires for each
Requirement: Deterministic id and clock sources
Event envelope id and timestamp SHALL come from injectable sources. The EventBus constructor accepts new EventBus<TMap>({ clock?, idGenerator? }) overrides; there is no createBus factory. The defaults live in two named exports (defaultClock, defaultIdGenerator) and nowhere else in src/.
Verified by: tests/no-disallowed.test.ts and src/conformance.spec-conformance.test.ts (constructor injection scenario).
Scenario: No module-scope non-determinism
- WHEN
src/is scanned forDate.now,crypto.randomUUID,Math.random - THEN those symbols appear only in
clock/default-clock.tsandid/id-generator.ts
Scenario: Injected clock and id generator flow to envelopes
- WHEN a bus is created with
new EventBus({ clock: () => 42, idGenerator: () => "X" })and an event is emitted - THEN the produced envelope has
timestamp === 42ANDid === "X"
Requirement: Passive observability adapter
The package SHALL export withObservability(bus, adapter) returning an unsubscribe handle. The adapter SHALL implement an optional subset of { onEmit, onEmitComplete, onEmitError }. No OpenTelemetry SDK is imported.
Verified by: src/observability/with-observability.test.ts and src/conformance.spec-conformance.test.ts (onEmitError scenario).
Scenario: Adapter hooks receive envelope and result on success
- WHEN an event is emitted through the wrapped bus and the dispatch chain resolves
- THEN
adapter.onEmit(envelope)fires once before listeners ANDadapter.onEmitComplete(envelope, result)fires once after ANDadapter.onEmitErrordoes NOT fire
Scenario: Adapter onEmitError fires when dispatch rejects
- WHEN an interceptor downstream of
withObservabilitythrows ORemitAsynctimes out ORemitAsyncis aborted - THEN
adapter.onEmitError(envelope, error)fires once with the rejectingEventsErrorAND the rejection propagates to the caller ANDadapter.onEmitCompletedoes NOT fire
Requirement: Test coverage at 100%
vitest.config.ts MUST enforce statements: 100, branches: 100, functions: 100, lines: 100. No istanbul ignore pragmas (they do not survive esbuild). Property-based tests cover FIFO order, interceptor composition, queue policies, and idempotency.
Verified by: vitest.config.ts thresholds and the full package test suite under bunx turbo test:coverage --filter=@suaveplan/events.
Scenario: Coverage threshold enforced
- WHEN
bunx turbo test:coverage --filter=@suaveplan/eventsruns - THEN it exits non-zero if any metric drops below 100
Requirement: emitAsync timeout
emitAsync(type, payload, { timeoutMs }) SHALL reject with EventsError("EVENTS.EMIT.TIMED_OUT") if the dispatch chain (interceptors + listeners) has not settled within timeoutMs milliseconds. Omitting timeoutMs disables the timeout entirely. Settlement before the deadline cancels the timer; the timeout MUST NOT leak a pending handle into the test runner.
Verified by: src/conformance.spec-conformance.test.ts (timeout scenario) and src/bus/event-bus.test.ts.
Scenario: Timeout rejects with EVENTS.EMIT.TIMED_OUT
- WHEN
emitAsync("x", v, { timeoutMs: 5 })is invoked AND a registered listener takes longer than 5ms to settle - THEN the returned promise rejects with
EventsErrorANDerror.code === "EVENTS.EMIT.TIMED_OUT"ANDerror.context.timeoutMs === 5
Scenario: No timeoutMs means no timer
- WHEN
emitAsync("x", v)is invoked with notimeoutMs - THEN the returned promise settles solely on dispatch outcome AND no
setTimeoutis scheduled
Requirement: emitAsync abort
emitAsync(type, payload, { signal }) SHALL honour an AbortSignal. If the signal is already aborted at dispatch start OR aborts between listener invocations, dispatch SHALL throw EventsError("EVENTS.EMIT.ABORTED") with cause === signal.reason. Listeners that have already started SHALL run to their natural completion (cooperative cancellation, not preemption).
Verified by: src/conformance.spec-conformance.test.ts (abort scenarios) and src/bus/event-bus.test.ts.
Scenario: Pre-aborted signal rejects emitAsync
- WHEN
emitAsync("x", v, { signal })is invoked withsignal.aborted === trueandsignal.reason === "stop" - THEN the returned promise rejects with
EventsErrorANDerror.code === "EVENTS.EMIT.ABORTED"ANDerror.cause === "stop"
Scenario: Mid-dispatch abort halts subsequent listeners
- WHEN listeners A, B are registered AND
emitAsync("x", v, { signal })runs AND A aborts the signal during its body - THEN A runs to completion AND B is NOT invoked AND the returned promise rejects with
EventsError("EVENTS.EMIT.ABORTED")
Requirement: Envelope traceId propagation
emit(type, payload, traceId?) and emitAsync(type, payload, { traceId? }) SHALL accept an optional traceId string and surface it on the produced EventEnvelope.traceId. Omitting traceId SHALL leave the envelope's traceId undefined (NOT generated, NOT defaulted). traceId MUST flow through interceptors and observability adapters unchanged.
Verified by: src/conformance.spec-conformance.test.ts (traceId scenarios) and src/envelope/envelope.test.ts.
Scenario: Provided traceId surfaces on envelope
- WHEN
emitAsync("x", v, { traceId: "trace-123" })is invoked AND an interceptor inspects the envelope - THEN
envelope.traceId === "trace-123"
Scenario: Omitted traceId leaves envelope undefined
- WHEN
emit("x", v)is invoked with notraceId - THEN
envelope.traceId === undefinedAND no fallback id is generated
Requirement: Event buffering for late subscribers
The EventBus constructor SHALL accept bufferSize?: number. When bufferSize > 0, every emitted event SHALL be stored in a rolling buffer per event type. When a new listener is registered via on(), the buffered events for that type SHALL be replayed to the listener synchronously before on() returns. Listener errors during replay SHALL be swallowed.
Verified by: src/bus/event-bus.test.ts (buffering scenarios).
Scenario: Late subscriber receives buffered events
- WHEN
emit("x", 1)andemit("x", 2)have already run AND a new listener is registered viaon("x", ...) - THEN the listener is called with
1then2beforeon()returns
Scenario: Buffer respects size limit
- WHEN
bufferSizeis2AND three events are emitted - THEN a late subscriber only receives the most recent
2events
Requirement: Wildcard pattern matching
The EventBus constructor SHALL accept wildcard?: boolean. When wildcard is true, listeners registered on patterns containing * SHALL be invoked for any emit whose type matches the pattern. Exact-match listeners SHALL fire before wildcard listeners.
Verified by: src/bus/event-bus.test.ts (wildcard scenarios).
Scenario: Wildcard subscriber matches multiple events
- WHEN a listener is registered on
"plugin:*"ANDemit("plugin:ready", 1)runs - THEN the wildcard listener fires with payload
1
Scenario: Wildcard disabled is default
- WHEN
wildcardis omitted orfalseAND a listener is registered on"plugin:*" - THEN
emit("plugin:ready", 1)does NOT invoke the wildcard listener
Requirement: Async iteration
The EventBus SHALL export iter<K>(type: K, signal?: AbortSignal): AsyncIterable<TMap[K]>. Each emitted event of the given type SHALL be yielded to the for await loop. Calling return() on the iterator or aborting the signal SHALL unsubscribe the listener and end iteration.
Verified by: src/bus/event-bus.test.ts (async iteration scenarios).
Scenario: for await yields emitted payloads
- WHEN
bus.iter("x")is consumed in afor awaitloop ANDemit("x", 1)thenemit("x", 2)run - THEN the loop yields
1then2
Scenario: Abort ends iteration
- WHEN an
AbortSignalpassed toiter()is aborted - THEN the
for awaitloop terminates
Requirement: Listener introspection
The EventBus SHALL export:
listeners<K>(type: K): Listener<TMap[K]>[]— returns a shallow copy of exact-match listenerslistenerCount<K>(type: K): number— returns the count of exact-match listenersoffAll<K>(type?: K): void— removes all listeners for the given type, or all listeners if no type is provided
Verified by: src/bus/event-bus.test.ts (listener management scenarios).
Requirement: prependListener
The EventBus SHALL export prepend<K>(type: K, listener: Listener<TMap[K]>): Unsubscribe. The listener SHALL be inserted at the head of the dispatch queue for that event type, so it fires before listeners added via on().
Verified by: src/bus/event-bus.test.ts (prepend scenario).
Scenario: prepended listener fires first
- WHEN
on("x", A)thenprepend("x", B)thenemit("x", v)run - THEN B fires before A
Requirement: once off round-trip
once(type, listener) SHALL register the original listener in an internal WeakMap so that off(type, originalListener) removes the once wrapper. The wrapper itself SHALL NOT be required for removal.
Verified by: src/bus/event-bus.test.ts (once + off scenario).
Scenario: off with original listener removes once registration
- WHEN
once("x", f)is called AND thenoff("x", f)is called - THEN
emit("x", v)does NOT invokef
Requirement: Concurrency control
emitAsync SHALL accept concurrency?: "sequential" | "parallel" | "allSettled". The default SHALL be "sequential".
sequential— listeners await in registration order (existing behaviour).parallel— all listeners are invoked concurrently; the promise resolves when all settle.allSettled— all listeners are invoked concurrently; the promise resolves when all settle, collecting every rejection intoEmitResult.errors.
Verified by: src/bus/event-bus.test.ts (concurrency scenarios).
Scenario: parallel runs listeners concurrently
- WHEN two slow listeners are registered AND
emitAsyncis called withconcurrency: "parallel" - THEN both listeners run concurrently, not sequentially
Scenario: allSettled collects all errors
- WHEN two listeners throw AND
emitAsyncis called withconcurrency: "allSettled" - THEN both errors appear in
EmitResult.errorsANDdelivered === 0
Requirement: maxListeners warning
The EventBus constructor SHALL accept maxListeners?: number (default 10). When the listener count for any event type exceeds this threshold, the bus SHALL emit a warning via the errorSink callback if one is configured.
Verified by: src/bus/event-bus.test.ts (maxListeners scenario).
Scenario: maxListeners exceeded routes to errorSink
- WHEN
maxListeners: 2is configured AND a third listener is added - THEN
errorSinkis called with anEventsErrorwhose code isEVENTS.LISTENER.MAX_EXCEEDED
Requirement: errorSink
The EventBus constructor SHALL accept errorSink?: (error: EventsError) => void. Every listener error captured during emit or emitAsync SHALL be routed to errorSink in addition to being placed in EmitResult.errors.
Verified by: src/bus/event-bus.test.ts (errorSink scenarios).
Scenario: errorSink receives sync listener errors
- WHEN a sync listener throws AND
errorSinkis configured - THEN
errorSinkis called with theEventsError
Scenario: errorSink receives async listener errors
- WHEN an async listener rejects AND
errorSinkis configured - THEN
errorSinkis called with theEventsError
Requirement: Disposable
EventBus SHALL implement Symbol.dispose. Calling [Symbol.dispose]() SHALL call clear(). After disposal, subsequent calls to emit or emitAsync SHALL throw EventsError("EVENTS.EMIT.ABORTED").
The Unsubscribe handle returned by on, once, prepend, and use SHALL also implement Symbol.dispose, equivalent to calling the function.
Verified by: src/bus/event-bus.test.ts (disposal scenarios).
Scenario: Bus dispose clears state
- WHEN
[Symbol.dispose]()is called on a bus with listeners - THEN subsequent
emitthrows AND subsequentemitAsyncrejects
Scenario: Unsubscribe handle dispose removes listener
- WHEN
const unsub = on("x", f)ANDunsub[Symbol.dispose]()is called - THEN
emit("x", v)does NOT invokef