Files
2026-05-27 23:29:53 +01:00

53 lines
3.0 KiB
Markdown

# Change: events-generics-and-interceptor-replace
## Why
`@suaveplan/events` shipped at 0.0.0 with 100% coverage but the implementation drifts from the spec in four places that matter for downstream `events-schema`, `events-otel`, `events-metrics`, and `events-transport`:
1. `Interceptor` is non-generic — every interceptor sees `EventEnvelope<unknown>` even when the bus is `EventBus<TMap>`. Forces unsafe casts in every adapter and undermines the typed-bus value proposition.
2. Spec scenario "Interceptor can replace payload" is unimplemented — `Next` takes no args, so an interceptor cannot pass a replacement envelope down the chain. `compose` already plumbs `env` through `run(index, env)`, only the public type prevents it.
3. Sync `emit()` silently drops rejections from listeners that return Promises — they become unhandled rejections instead of landing in `EmitResult.errors`.
4. `Listener<T>` is duplicated in `idempotent.ts` instead of importing from the bus types — a drift trap.
These are correctness fixes, not new features. Public-surface-compatible: existing call sites keep working because the new generic parameter has a default and the new `Next` envelope arg is optional.
## What Changes
### `Interceptor` becomes `Interceptor<TMap = EventMap>`
```ts
type Interceptor<TMap extends EventMap = EventMap> = <K extends keyof TMap>(
envelope: EventEnvelope<TMap[K]>,
next: Next<TMap, K>
) => Promise<EmitResult>;
```
`EventBus<TMap>.use(interceptor: Interceptor<TMap>)` narrows to the bus's map. Existing untyped interceptors keep working through the default.
### `Next` accepts an optional replacement envelope
```ts
type Next<TMap extends EventMap = EventMap, K extends keyof TMap = keyof TMap> =
(envelope?: EventEnvelope<TMap[K]>) => Promise<EmitResult>;
```
Calling `next()` advances with the current envelope. Calling `next(replacement)` advances with the replacement; downstream listeners observe `replacement.payload`.
### Sync `emit` captures async rejections
When a listener returns a thenable, `emit` attaches a `.catch()` that wraps the rejection as `EventsError("EVENTS.LISTENER.THREW")` and pushes it onto a deferred-error channel exposed via `EmitResult.deferredErrors: Promise<readonly EventsError[]>`. Synchronous throws still land in `errors[]` as before. This is additive — `errors` semantics unchanged.
### Sync `emit` interceptor behavior — documented bypass
Sync `emit` does NOT run async interceptors. Document this explicitly in `event-bus.md` and surface a runtime warning hook (`onSyncEmitWithInterceptors?: (count) => void`) on `EventBusOptions` so adapters can detect mixed usage. Consumers wanting interceptor coverage use `emitAsync`.
### `idempotent` reuses `Listener<T>` from bus types
Drop the local `Listener<T>` declaration; import from `../bus/event-bus.types.js`.
## Impact
- `@suaveplan/events`: minor (semver-additive within 0.0.x)
- Unblocks proper typing in `events-schema`, `events-otel`, `events-metrics`, `events-transport`
- No external consumers yet — published only to Verdaccio at 0.0.0