12 KiB
MODIFIED 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: 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: 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: 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/conformance.spec-conformance.test.ts (sync + async listener-throw scenarios) and src/bus/event-bus.test.ts.
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: 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: src/conformance.spec-conformance.test.ts (constructor injection scenario) and tests/no-disallowed.test.ts.
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
ADDED Requirements
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 scenario) 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 scenario) 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