Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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