147 lines
6.5 KiB
Markdown
147 lines
6.5 KiB
Markdown
## 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
|