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,51 @@
# OpenSpec Change: events-spec-tightening
- **Change ID**: events-spec-tightening
- **Package**: `@suaveplan/events`
- **Status**: PROPOSED
- **Date**: 2026-04-27
## Summary
Tighten `openspec/specs/events/spec.md` to cover behaviors already shipped by `@suaveplan/events`: `emitAsync` timeout (`EVENTS.EMIT.TIMED_OUT`), abort (`EVENTS.EMIT.ABORTED`), envelope `traceId` plumbing, and the `onEmitError` observability hook. Resolve the `createBus` vs `new EventBus` API ambiguity (impl ships only the class) and add an explicit async listener-throw scenario. Pin every gap behind a `*.spec-conformance.test.ts` so future drift exits the 5-gate non-zero.
## Current state
`@suaveplan/events` is fully implemented:
- `EventBus<TMap>` exposes `new EventBus({ clock?, idGenerator? })`; no `createBus` factory.
- `emitAsync(type, payload, { signal?, timeoutMs?, traceId? })` — `withTimeout` wraps the dispatch chain and rejects with `EventsError("EVENTS.EMIT.TIMED_OUT")` when `timeoutMs` elapses.
- Per-listener `signal.aborted` check throws `EventsError("EVENTS.EMIT.ABORTED")` with `cause === signal.reason`.
- `wrap` accepts and emits `traceId` on `EventEnvelope`.
- `withObservability(bus, adapter)` registers an interceptor that calls `adapter.onEmit?` / `onEmitComplete?` / `onEmitError?`.
- `EVENTS_CODES` no longer carries `SCHEMA.REJECTED` (delegated to the future `events-schema` interceptor package).
The spec, however, still says `createBus({ clock?, idGenerator? })` (line 118) and has zero scenarios for timeout, abort, `traceId`, `onEmitError`, or async listener-throw. A consumer reading the spec gets a misleading picture of the public API surface, and conformance can silently regress without a single CI signal.
## What changes
1. **Modify Requirement: Deterministic id and clock sources** — replace `createBus({ clock?, idGenerator? })` with `new EventBus({ clock?, idGenerator? })`. The class constructor is the canonical injection point.
2. **Modify Requirement: Structured errors** — add a scenario covering async listener throw (parallel to the existing sync-emit scenario), since `dispatch()` and `emit()` capture errors via independent paths.
3. **Modify Requirement: Passive observability adapter** — add a scenario covering `adapter.onEmitError(envelope, error)` firing when the dispatch chain rejects.
4. **ADD Requirement: emitAsync timeout** — codify `timeoutMs` semantics and `EVENTS.EMIT.TIMED_OUT`.
5. **ADD Requirement: emitAsync abort** — codify `signal` semantics and `EVENTS.EMIT.ABORTED` (including `cause === signal.reason`).
6. **ADD Requirement: Envelope traceId propagation** — codify that `emit`/`emitAsync` accept `traceId` and surface it on `EventEnvelope.traceId`.
7. **Add `src/conformance.spec-conformance.test.ts`** pinning the new scenarios so the 5-gate publish flow catches drift.
8. **Add `**Verified by:**` lines** to every requirement (matching the conformance convention used by `datetime`, `i18n`, and other recently-folded packages).
## Why now
These are the five spec gaps surfaced during a focused enterprise-quality audit of `openspec/archive/2026-04-19-enterprise-events/`. The implementation has the behaviors today (and 100% coverage), but the spec is the contract — without scenarios, a future refactor can drop timeout handling and ship green. Tightening now is cheap (~30 minutes) and prevents both phantom enhancement requests and silent behavior regression in the substrate every domain-event-emitting package depends on.
## Impact
- `@suaveplan/events` — no runtime changes. Adds one `*.spec-conformance.test.ts` (~6 scenarios). Coverage stays at 100%.
- Downstream `events-metrics`, `events-otel`, `events-schema`, `events-transport` — none affected; their interceptor contract is unchanged.
- Spec readers — get an accurate picture of the API including timeout / abort / traceId / onEmitError, which removes the most likely sources of misuse.

View File

@@ -0,0 +1,226 @@
## MODIFIED 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.
**Verified by:** `tests/dep-graph.test.ts` and `tests/no-disallowed.test.ts`.
#### 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`.
**Verified by:** `src/bus/event-bus.test.ts` (typed bus scenarios).
#### 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.
**Verified by:** `src/bus/event-bus.test.ts` (sync + async ordering scenarios).
#### 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.
**Verified by:** `src/interceptor/compose.test.ts` and `src/bus/event-bus.test.ts` (interceptor scenarios).
#### 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: 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`.
**Verified by:** `src/queue/bounded-queue.test.ts`.
#### 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)`.
**Verified by:** `src/idempotent/idempotent.test.ts`.
#### 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: 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.
**Verified by:** `vitest.config.ts` thresholds and the full package test suite under `bunx turbo test:coverage --filter=@suaveplan/events`.
#### Scenario: Coverage threshold enforced
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/events` runs
- **THEN** it exits non-zero if any metric drops below 100
### 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. This applies to both `emit` (sync) and `emitAsync` (async) — both paths capture listener throws into `EmitResult.errors` rather than rejecting the dispatch.
**Verified by:** `src/conformance.spec-conformance.test.ts` (sync + async listener-throw scenarios) and `src/bus/event-bus.test.ts`.
#### Scenario: Sync listener throw is captured
- **WHEN** a sync listener throws on `emit`
- **THEN** `emit` returns `EmitResult` with `errors[0].code === "EVENTS.LISTENER.THREW"` AND `errors[0].cause === the thrown value` AND `delivered` reflects successful deliveries only
#### Scenario: Async listener throw is captured
- **WHEN** an async listener rejects on `emitAsync`
- **THEN** the returned `Promise<EmitResult>` resolves (does not reject) AND `errors[0].code === "EVENTS.LISTENER.THREW"` AND `errors[0].cause === the rejection value` AND `delivered` reflects successful deliveries only
### Requirement: Deterministic id and clock sources
Event envelope `id` and `timestamp` SHALL come from injectable sources. The `EventBus` constructor accepts `new EventBus<TMap>({ clock?, idGenerator? })` overrides; there is no `createBus` factory. The defaults live in two named exports (`defaultClock`, `defaultIdGenerator`) and nowhere else in `src/`.
**Verified by:** `src/conformance.spec-conformance.test.ts` (constructor injection scenario) and `tests/no-disallowed.test.ts`.
#### 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 `new EventBus({ 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 an unsubscribe handle. The adapter SHALL implement an optional subset of `{ onEmit, onEmitComplete, onEmitError }`. No OpenTelemetry SDK is imported.
**Verified by:** `src/observability/with-observability.test.ts` and `src/conformance.spec-conformance.test.ts` (onEmitError scenario).
#### Scenario: Adapter hooks receive envelope and result on success
- **WHEN** an event is emitted through the wrapped bus and the dispatch chain resolves
- **THEN** `adapter.onEmit(envelope)` fires once before listeners AND `adapter.onEmitComplete(envelope, result)` fires once after AND `adapter.onEmitError` does NOT fire
#### Scenario: Adapter onEmitError fires when dispatch rejects
- **WHEN** an interceptor downstream of `withObservability` throws OR `emitAsync` times out OR `emitAsync` is aborted
- **THEN** `adapter.onEmitError(envelope, error)` fires once with the rejecting `EventsError` AND the rejection propagates to the caller AND `adapter.onEmitComplete` does NOT fire
## ADDED Requirements
### Requirement: emitAsync timeout
`emitAsync(type, payload, { timeoutMs })` SHALL reject with `EventsError("EVENTS.EMIT.TIMED_OUT")` if the dispatch chain (interceptors + listeners) has not settled within `timeoutMs` milliseconds. Omitting `timeoutMs` disables the timeout entirely. Settlement before the deadline cancels the timer; the timeout MUST NOT leak a pending handle into the test runner.
**Verified by:** `src/conformance.spec-conformance.test.ts` (timeout scenario) and `src/bus/event-bus.test.ts`.
#### Scenario: Timeout rejects with EVENTS.EMIT.TIMED_OUT
- **WHEN** `emitAsync("x", v, { timeoutMs: 5 })` is invoked AND a registered listener takes longer than 5ms to settle
- **THEN** the returned promise rejects with `EventsError` AND `error.code === "EVENTS.EMIT.TIMED_OUT"` AND `error.context.timeoutMs === 5`
#### Scenario: No timeoutMs means no timer
- **WHEN** `emitAsync("x", v)` is invoked with no `timeoutMs`
- **THEN** the returned promise settles solely on dispatch outcome AND no `setTimeout` is scheduled
### Requirement: emitAsync abort
`emitAsync(type, payload, { signal })` SHALL honour an `AbortSignal`. If the signal is already aborted at dispatch start OR aborts between listener invocations, dispatch SHALL throw `EventsError("EVENTS.EMIT.ABORTED")` with `cause === signal.reason`. Listeners that have already started SHALL run to their natural completion (cooperative cancellation, not preemption).
**Verified by:** `src/conformance.spec-conformance.test.ts` (abort scenario) and `src/bus/event-bus.test.ts`.
#### Scenario: Pre-aborted signal rejects emitAsync
- **WHEN** `emitAsync("x", v, { signal })` is invoked with `signal.aborted === true` and `signal.reason === "stop"`
- **THEN** the returned promise rejects with `EventsError` AND `error.code === "EVENTS.EMIT.ABORTED"` AND `error.cause === "stop"`
#### Scenario: Mid-dispatch abort halts subsequent listeners
- **WHEN** listeners A, B are registered AND `emitAsync("x", v, { signal })` runs AND A aborts the signal during its body
- **THEN** A runs to completion AND B is NOT invoked AND the returned promise rejects with `EventsError("EVENTS.EMIT.ABORTED")`
### Requirement: Envelope traceId propagation
`emit(type, payload, traceId?)` and `emitAsync(type, payload, { traceId? })` SHALL accept an optional `traceId` string and surface it on the produced `EventEnvelope.traceId`. Omitting `traceId` SHALL leave the envelope's `traceId` undefined (NOT generated, NOT defaulted). `traceId` MUST flow through interceptors and observability adapters unchanged.
**Verified by:** `src/conformance.spec-conformance.test.ts` (traceId scenario) and `src/envelope/envelope.test.ts`.
#### Scenario: Provided traceId surfaces on envelope
- **WHEN** `emitAsync("x", v, { traceId: "trace-123" })` is invoked AND an interceptor inspects the envelope
- **THEN** `envelope.traceId === "trace-123"`
#### Scenario: Omitted traceId leaves envelope undefined
- **WHEN** `emit("x", v)` is invoked with no `traceId`
- **THEN** `envelope.traceId === undefined` AND no fallback id is generated

View File

@@ -0,0 +1,27 @@
# Tasks: events-spec-tightening
## 1. Spec delta
- [ ] 1.1 Author `specs/events/spec.md` delta with `## MODIFIED Requirements` (Deterministic id and clock sources, Structured errors, Passive observability adapter) and `## ADDED Requirements` (emitAsync timeout, emitAsync abort, Envelope traceId propagation).
- [ ] 1.2 Add `**Verified by:**` line to every requirement (existing + new).
## 2. Conformance test
- [ ] 2.1 Add `packages/events/events/src/conformance.spec-conformance.test.ts` pinning:
- `new EventBus({ clock, idGenerator })` accepts injections (replaces ghost `createBus`).
- Async listener throw is captured into `EmitResult.errors` with `EVENTS.LISTENER.THREW`.
- `withObservability` adapter `onEmitError` fires when dispatch rejects.
- `emitAsync` with `timeoutMs` rejects with `EVENTS.EMIT.TIMED_OUT` past the deadline.
- `emitAsync` with an aborted `AbortSignal` rejects with `EVENTS.EMIT.ABORTED` (`cause === signal.reason`).
- `traceId` passed to `emit` / `emitAsync` surfaces on `EventEnvelope.traceId`.
## 3. Gate
- [ ] 3.1 `bunx turbo lint typecheck test --filter=@suaveplan/events` green.
- [ ] 3.2 `bunx turbo test:coverage --filter=@suaveplan/events` 100/100/100/100.
- [ ] 3.3 `bun scripts/openspec-conformance.ts @suaveplan/events` exits 0.
## 4. Fold + archive
- [ ] 4.1 Fold delta into `openspec/specs/events/spec.md`.
- [ ] 4.2 Move `openspec/changes/events-spec-tightening/` to `openspec/archive/2026-04-27-events-spec-tightening/`.