6.5 KiB
ADDED 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.
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.
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.
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.
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.
Scenario: Listener throw is captured
- WHEN a sync listener throws
- THEN
emitreturnsEmitResultwitherrors[0].code === "EVENTS.LISTENER.THREW"ANDerrors[0].cause === the thrown 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.
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).
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. createBus({ clock?, idGenerator? }) accepts overrides. The defaults live in two named exports (defaultClock, defaultIdGenerator) and nowhere else in src/.
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
{ 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 a wrapped bus that invokes adapter hooks around each emit. No OpenTelemetry SDK is imported.
Scenario: Adapter hooks receive envelope and result
- WHEN an event is emitted through the wrapped bus
- THEN
adapter.onEmit(envelope)fires once before listeners ANDadapter.onEmitComplete(envelope, result)fires once after
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.
Scenario: Coverage threshold enforced
- WHEN
bunx turbo test:coverage --filter=@suaveplan/eventsruns - THEN it exits non-zero if any metric drops below 100