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

3.0 KiB

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>

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

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