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

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 dependencies is read from package.json
  • THEN the set equals { @suaveplan/error, tslib, zod }

Scenario: No Node / browser built-ins leak

  • WHEN src/ is scanned for node:*, 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 calls bus.emit("login", x)
  • THEN x is typed { user: string } (not unknown)

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" and emit("x", v) runs
  • THEN A runs to completion, then B, then C

Scenario: Async order

  • WHEN async listeners A, B are registered on "x" and emitAsync("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 emitAsync rejects with EventsError("EVENTS.INTERCEPTOR.THREW") with cause === 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 emit returns EmitResult with errors[0].code === "EVENTS.LISTENER.THREW" AND errors[0].cause === the thrown value AND delivered reflects 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 <= 0 or 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 for Date.now, crypto.randomUUID, Math.random
  • THEN those symbols appear only in clock/default-clock.ts and id/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 === 42 AND id === "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 AND adapter.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/events runs
  • THEN it exits non-zero if any metric drops below 100