Initial Commit
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# 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
|
||||
@@ -0,0 +1,55 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Interceptor API
|
||||
|
||||
The package SHALL export `bus.use(interceptor)` registering an interceptor of shape `<K extends keyof TMap>(envelope: EventEnvelope<TMap[K]>, next: Next<TMap, K>) => Promise<EmitResult>`. `Interceptor<TMap>` and `Next<TMap, K>` are generic over the bus's event map. `Next` accepts an optional replacement envelope so an interceptor MAY rewrite the envelope before downstream interceptors and listeners observe it.
|
||||
|
||||
#### Scenario: Interceptor envelope is typed
|
||||
|
||||
- **WHEN** a consumer registers `bus.use<{login: {user: string}}>` an interceptor on a `EventBus<{login: {user: string}}>`
|
||||
- **THEN** `envelope.payload` is typed `{user: string}` inside the interceptor body (not `unknown`)
|
||||
|
||||
#### 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 envelope via next()
|
||||
|
||||
- **WHEN** an interceptor invokes `next(replacement)` with a new `EventEnvelope`
|
||||
- **THEN** downstream interceptors and listeners receive the replacement envelope AND `replacement.payload` is delivered to listeners
|
||||
|
||||
### Requirement: Structured errors
|
||||
|
||||
All thrown errors SHALL be `EventsError extends SuaveplanError<EventsCode>` with codes prefixed `EVENTS.`. Listener failures SHALL be collected into `EmitResult.errors` (synchronous failures) or `EmitResult.deferredErrors` (failures from listener-returned promises in sync `emit`), not propagated, unless an interceptor explicitly re-throws.
|
||||
|
||||
#### Scenario: Listener throw is captured
|
||||
|
||||
- **WHEN** a sync listener throws during `emit`
|
||||
- **THEN** `emit` returns `EmitResult` with `errors[0].code === "EVENTS.LISTENER.THREW"` AND `errors[0].cause === the thrown value` AND `delivered` reflects successful synchronous deliveries only
|
||||
|
||||
#### Scenario: Async-listener rejection captured by sync emit
|
||||
|
||||
- **WHEN** a listener registered via `bus.on()` returns a `Promise` that rejects AND `bus.emit()` is called
|
||||
- **THEN** the synchronous return of `emit()` resolves with `delivered` counting the dispatched listener (call did not throw) AND `EmitResult.deferredErrors` resolves to a list containing an `EventsError("EVENTS.LISTENER.THREW")` whose `cause` is the rejection value
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Sync emit bypasses interceptors with detection hook
|
||||
|
||||
Sync `emit()` SHALL NOT run async interceptors (interceptors are asynchronous and `emit()` is synchronous). The bus SHALL accept an optional `onSyncEmitWithInterceptors` callback in `EventBusOptions` that fires once per sync `emit` whenever interceptors are registered, allowing adapters to detect mixed usage.
|
||||
|
||||
#### Scenario: Sync emit with interceptors triggers warning hook
|
||||
|
||||
- **WHEN** `bus.emit("x", v)` is called on a bus with at least one registered interceptor AND `onSyncEmitWithInterceptors` is provided
|
||||
- **THEN** `onSyncEmitWithInterceptors(count)` is invoked with the registered interceptor count AND interceptors do not run AND listeners still fire in registration order
|
||||
|
||||
#### Scenario: Sync emit without interceptors does not invoke hook
|
||||
|
||||
- **WHEN** `bus.emit("x", v)` is called and no interceptors are registered
|
||||
- **THEN** the hook is not invoked
|
||||
@@ -0,0 +1,16 @@
|
||||
> Archived 2026-04-26: all tasks ticked; change shipped under packages/events/events.
|
||||
|
||||
# Tasks: events-generics-and-interceptor-replace
|
||||
|
||||
- [x] 1. Update `interceptor/compose.ts` — add `TMap` generic, optional envelope arg on `Next`
|
||||
- [x] 2. Update `bus/event-bus.ts` — pass `TMap`-bound interceptor list, thread replacement envelope through dispatch
|
||||
- [x] 3. Capture deferred listener-promise rejections in sync `emit`; add `EmitResult.deferredErrors`
|
||||
- [x] 4. Add `onSyncEmitWithInterceptors` option + document sync-emit interceptor bypass in `event-bus.md`
|
||||
- [x] 5. Replace duplicate `Listener<T>` in `idempotent/idempotent.ts` with import from bus types
|
||||
- [x] 6. Update `observability/with-observability.ts` to use the typed interceptor
|
||||
- [x] 7. Tests: typed interceptor envelope, envelope replacement via `next(replacement)`, deferred-rejection capture, sync-emit warning hook fires
|
||||
- [x] 8. Update sibling `.md` docs for changed modules (compose, event-bus, idempotent)
|
||||
- [x] 9. `bunx turbo lint typecheck test --filter=@suaveplan/events` green
|
||||
- [x] 10. `bunx turbo test:coverage --filter=@suaveplan/events` at 100/100/100/100
|
||||
- [x] 11. Build: `bunx turbo build --filter=@suaveplan/events` green
|
||||
- [x] 12. Fold spec deltas into `openspec/specs/events/spec.md`; archive change folder
|
||||
Reference in New Issue
Block a user