Initial Commit
This commit is contained in:
176
openspec/archive/2026-04-19-enterprise-events/design.md
Normal file
176
openspec/archive/2026-04-19-enterprise-events/design.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Design: enterprise-events
|
||||
|
||||
## Module layout
|
||||
|
||||
```text
|
||||
packages/universal/events/
|
||||
├── package.json # deps: @suaveplan/error, tslib, zod
|
||||
├── tsconfig.lib.json
|
||||
├── vite.config.ts
|
||||
├── vitest.config.ts # thresholds 100/100/100/100
|
||||
├── README.md
|
||||
├── src/
|
||||
│ ├── index.ts # barrel
|
||||
│ ├── constants/index.ts
|
||||
│ ├── errors/
|
||||
│ │ ├── codes.ts
|
||||
│ │ ├── events-error.ts
|
||||
│ │ └── events-error.test.ts
|
||||
│ ├── envelope/
|
||||
│ │ ├── envelope.ts # wrap(type, payload, options)
|
||||
│ │ ├── envelope.md
|
||||
│ │ ├── envelope.test.ts
|
||||
│ │ ├── envelope.types.ts
|
||||
│ │ └── envelope.schemas.ts # Zod (opt-in)
|
||||
│ ├── id/
|
||||
│ │ ├── id-generator.ts
|
||||
│ │ ├── id-generator.md
|
||||
│ │ └── id-generator.test.ts
|
||||
│ ├── clock/
|
||||
│ │ ├── default-clock.ts
|
||||
│ │ ├── default-clock.md
|
||||
│ │ └── default-clock.test.ts
|
||||
│ ├── bus/
|
||||
│ │ ├── event-bus.ts # EventBus<TMap> class
|
||||
│ │ ├── event-bus.md
|
||||
│ │ ├── event-bus.test.ts
|
||||
│ │ └── event-bus.types.ts
|
||||
│ ├── interceptor/
|
||||
│ │ ├── compose.ts # compose interceptors into a chain
|
||||
│ │ ├── compose.md
|
||||
│ │ └── compose.test.ts
|
||||
│ ├── queue/
|
||||
│ │ ├── bounded-queue.ts
|
||||
│ │ ├── bounded-queue.md
|
||||
│ │ └── bounded-queue.test.ts
|
||||
│ ├── idempotent/
|
||||
│ │ ├── idempotent.ts
|
||||
│ │ ├── idempotent.md
|
||||
│ │ └── idempotent.test.ts
|
||||
│ ├── observability/
|
||||
│ │ ├── with-observability.ts
|
||||
│ │ ├── with-observability.md
|
||||
│ │ └── with-observability.test.ts
|
||||
│ └── __benchmarks__/
|
||||
│ └── dispatch.bench.ts
|
||||
├── tests/
|
||||
│ ├── setup/polyfills.ts
|
||||
│ ├── dep-graph.test.ts
|
||||
│ ├── no-disallowed.test.ts
|
||||
│ └── tree-shake.test.ts
|
||||
└── CHANGELOG.md
|
||||
```
|
||||
|
||||
## Key design decisions
|
||||
|
||||
### Typed `EventBus<TMap>`
|
||||
|
||||
```ts
|
||||
type EventMap = Record<string, unknown>;
|
||||
|
||||
class EventBus<TMap extends EventMap> {
|
||||
on<K extends keyof TMap>(type: K, listener: Listener<TMap[K]>): Unsubscribe;
|
||||
once<K extends keyof TMap>(type: K, listener: Listener<TMap[K]>): Unsubscribe;
|
||||
off<K extends keyof TMap>(type: K, listener: Listener<TMap[K]>): void;
|
||||
emit<K extends keyof TMap>(type: K, payload: TMap[K]): EmitResult;
|
||||
emitAsync<K extends keyof TMap>(
|
||||
type: K,
|
||||
payload: TMap[K],
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number }
|
||||
): Promise<EmitResult>;
|
||||
use(interceptor: Interceptor<TMap>): Unsubscribe;
|
||||
clear(): void;
|
||||
}
|
||||
```
|
||||
|
||||
`EmitResult`: `{ delivered: number; errors: readonly EventsError[] }`. Sync `emit` collects listener errors; async collects + surfaces as `errors[]` (never throws mid-dispatch; interceptors CAN throw to halt).
|
||||
|
||||
### Interceptor chain
|
||||
|
||||
Interceptors run in registration order; last-registered is innermost. Signature:
|
||||
|
||||
```ts
|
||||
type Interceptor<TMap> = <K extends keyof TMap>(
|
||||
envelope: EventEnvelope<TMap[K]>,
|
||||
next: () => Promise<EmitResult>
|
||||
) => Promise<EmitResult>;
|
||||
```
|
||||
|
||||
The bus composes them with `compose(interceptors)` into a single function. Calling `next()` advances; not calling it drops the event (no listeners fire). Throwing aborts the emit and propagates the rejection.
|
||||
|
||||
### BoundedQueue
|
||||
|
||||
Independent of the bus — used internally when `emitAsync` is called with `{ queue: true }` and externally by consumers who want the same backpressure primitive.
|
||||
|
||||
```ts
|
||||
interface BoundedQueueOptions {
|
||||
capacity: number;
|
||||
policy: "drop-oldest" | "drop-newest" | "block" | "throw";
|
||||
}
|
||||
|
||||
class BoundedQueue<T> {
|
||||
enqueue(item: T, signal?: AbortSignal): Promise<void>;
|
||||
dequeue(): T | undefined;
|
||||
drain(): T[];
|
||||
get size(): number;
|
||||
}
|
||||
```
|
||||
|
||||
`block` waits for room (Promise resolves when dequeued). `drop-oldest` evicts the head. `drop-newest` rejects the new enqueue silently. `throw` throws `EventsError("EVENTS.QUEUE.OVERFLOW")`. `enqueue` with a signal that aborts rejects with `EventsError("EVENTS.QUEUE.ABORTED")`.
|
||||
|
||||
### `idempotent`
|
||||
|
||||
Wraps a listener so a given key only fires once. Default cache is a `Map<string, true>`; consumers can pass `{ cache: LRUCache }` to bound growth.
|
||||
|
||||
```ts
|
||||
function idempotent<T>(
|
||||
listener: Listener<T>,
|
||||
options: { keyOf: (payload: T) => string; cache?: IdempotencyCache }
|
||||
): Listener<T>;
|
||||
```
|
||||
|
||||
### `withObservability`
|
||||
|
||||
Not an interceptor — a passive adapter that receives hooks. Counters/histograms/spans live in `events-metrics` / `events-otel`; this package exposes only the hook shape.
|
||||
|
||||
### Determinism
|
||||
|
||||
Two single-location module references:
|
||||
|
||||
```ts
|
||||
// clock/default-clock.ts
|
||||
export const defaultClock = (): number => Date.now();
|
||||
// id/id-generator.ts
|
||||
export const defaultIdGenerator = (): string => crypto.randomUUID();
|
||||
```
|
||||
|
||||
Every other file accepts an injection. The two defaults are the only places `Date.now()` / `crypto.randomUUID()` appear in `src/`.
|
||||
|
||||
### Error codes
|
||||
|
||||
```ts
|
||||
export const EVENTS_CODES = defineCodes("EVENTS", [
|
||||
"LISTENER.THREW",
|
||||
"INTERCEPTOR.THREW",
|
||||
"QUEUE.OVERFLOW",
|
||||
"QUEUE.ABORTED",
|
||||
"QUEUE.INVALID_CAPACITY",
|
||||
"SCHEMA.REJECTED",
|
||||
"EMIT.TIMED_OUT",
|
||||
"EMIT.ABORTED",
|
||||
] as const);
|
||||
```
|
||||
|
||||
## Coverage strategy
|
||||
|
||||
- Enumeration tests for every branch
|
||||
- Property-based (fast-check) for: ordering invariants, interceptor composition (associativity), queue policy state machines, idempotency
|
||||
- No `istanbul ignore` pragmas — defensive branches removed or rewritten as `as` assertions with invariant comments
|
||||
- Module-level determinism enforced by `tests/no-disallowed.test.ts` (greps for `Math.random` / `Date.now` / `crypto.randomUUID` outside the two approved files)
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Not shipping an OTel SDK — `withObservability` produces the hook shape; `events-otel` will implement the adapter
|
||||
- Not shipping a transport — `events-transport` plugs in as an interceptor
|
||||
- Not shipping metrics — `events-metrics` plugs in via `withObservability`
|
||||
- Not shipping a schema registry — `events-schema` plugs in as an interceptor
|
||||
59
openspec/archive/2026-04-19-enterprise-events/proposal.md
Normal file
59
openspec/archive/2026-04-19-enterprise-events/proposal.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Change: enterprise-events
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/events` is the foundation pub/sub substrate — typed event bus, deterministic ordering, backpressure-aware async dispatch, and a public companion/interceptor API that the sibling packages (`events-metrics`, `events-otel`, `events-schema`, `events-transport`) plug into without monkey-patching. Every domain-event-emitting package will depend on it.
|
||||
|
||||
Per the foundation-100-coverage rule (see memory `foundation-100-coverage`), this ships at **100%** coverage from day one.
|
||||
|
||||
## What Changes
|
||||
|
||||
### New package: `packages/universal/events`
|
||||
|
||||
Ships as a focused, zero-Node / zero-browser / zero-React TypeScript library. Runtime deps limited to `@suaveplan/error`, `tslib`, and `zod`.
|
||||
|
||||
#### Core surface
|
||||
|
||||
- **`EventBus<TMap>`** — typed bus parameterized by an event-name → payload map. Methods: `on`, `once`, `off`, `emit` (sync), `emitAsync` (async with AbortSignal), `use` (interceptor), `clear`
|
||||
- **`EventEnvelope<T>`** — `{ id, type, payload, timestamp, traceId? }`, produced by `wrap(type, payload, options?)`
|
||||
- **`createIdGenerator()`** — deterministic-under-injection id generator (default: `crypto.randomUUID()` via a single module-local reference that tests can swap)
|
||||
- **`Interceptor<TMap>`** — `(envelope, next) => R | Promise<R>` chain-of-responsibility. Registration order defines execution order; `next()` advances the chain; interceptors can replace, drop (by not calling `next`), or observe
|
||||
- **`BoundedQueue<T>`** — in-memory FIFO with `{ capacity, policy: "drop-oldest" | "drop-newest" | "block" | "throw" }`, used internally by `emitAsync` under backpressure and exposed for consumers
|
||||
- **`idempotent(fn, { keyOf, cache? })`** — wraps a handler so a given `keyOf(envelope)` only fires once; the cache is a `Map` by default, pluggable
|
||||
|
||||
#### Error surface
|
||||
|
||||
- **`EventsError`** extends `SuaveplanError<EventsCode>` with codes like `EVENTS.LISTENER.THREW`, `EVENTS.QUEUE.OVERFLOW`, `EVENTS.QUEUE.ABORTED`, `EVENTS.SCHEMA.REJECTED`, `EVENTS.INTERCEPTOR.THREW`
|
||||
- Errors capture `{ type, envelopeId, listenerIndex?, queueSize? }` in `context` and preserve the original `cause`
|
||||
|
||||
#### Schemas
|
||||
|
||||
- **`EventEnvelopeSchemas`** (`.schemas.ts`) — Zod schema for the envelope shape with generic payload; consumers opt in by importing `@suaveplan/events/envelope.schemas`
|
||||
|
||||
#### Observability hook
|
||||
|
||||
- **`withObservability(bus, adapter)`** — wraps an `EventBus` with a passive adapter implementing `{ onEmit?, onEmitError?, onEmitComplete?, onListenerThrew? }`; `events-metrics` / `events-otel` register here
|
||||
|
||||
#### Companion/interceptor API
|
||||
|
||||
- `bus.use(interceptor)` is the one public extension point; `events-schema` validates envelopes via an interceptor, `events-transport` forwards to an external sink via an interceptor, `events-metrics` times via an interceptor, `events-otel` creates spans via an interceptor. The core ships no direct coupling to any of those.
|
||||
|
||||
### Determinism
|
||||
|
||||
- No `Math.random()` / `Date.now()` at module scope
|
||||
- `createBus({ clock, idGenerator })` accepts injections; defaults are the thin wrappers `defaultClock = () => Date.now()` and `defaultIdGenerator = () => crypto.randomUUID()`
|
||||
|
||||
### Coverage
|
||||
|
||||
Vitest thresholds locked at 100/100/100/100 in `vitest.config.ts`. Property-based tests (fast-check) cover:
|
||||
|
||||
- dispatch ordering (FIFO across listeners and across emits)
|
||||
- interceptor composition (associativity, drop-propagation)
|
||||
- backpressure policies (bounded queue invariants)
|
||||
- idempotency (given key-collisions, handler called exactly once)
|
||||
|
||||
## Impact
|
||||
|
||||
- Unblocks `events-metrics`, `events-otel`, `events-schema`, `events-transport` (each a Layer 2 adapter on top of `@suaveplan/events`)
|
||||
- Depends on `@suaveplan/error` (already published) for `SuaveplanError` and `defineCodes`
|
||||
- No existing genesis consumers affected
|
||||
@@ -0,0 +1,146 @@
|
||||
## ADDED 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.
|
||||
|
||||
#### 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`.
|
||||
|
||||
#### 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.
|
||||
|
||||
#### 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.
|
||||
|
||||
#### 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: 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.
|
||||
|
||||
#### Scenario: Listener throw is captured
|
||||
|
||||
- **WHEN** a sync listener throws
|
||||
- **THEN** `emit` returns `EmitResult` with `errors[0].code === "EVENTS.LISTENER.THREW"` AND `errors[0].cause === the thrown value` AND `delivered` reflects successful deliveries only
|
||||
|
||||
### 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`.
|
||||
|
||||
#### 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)`.
|
||||
|
||||
#### 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: Deterministic id and clock sources
|
||||
|
||||
Event envelope `id` and `timestamp` SHALL come from injectable sources. `createBus({ clock?, idGenerator? })` accepts overrides. The defaults live in two named exports (`defaultClock`, `defaultIdGenerator`) and nowhere else in `src/`.
|
||||
|
||||
#### 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 `{ 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 a wrapped bus that invokes adapter hooks around each emit. No OpenTelemetry SDK is imported.
|
||||
|
||||
#### Scenario: Adapter hooks receive envelope and result
|
||||
|
||||
- **WHEN** an event is emitted through the wrapped bus
|
||||
- **THEN** `adapter.onEmit(envelope)` fires once before listeners AND `adapter.onEmitComplete(envelope, result)` fires once after
|
||||
|
||||
### 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.
|
||||
|
||||
#### Scenario: Coverage threshold enforced
|
||||
|
||||
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/events` runs
|
||||
- **THEN** it exits non-zero if any metric drops below 100
|
||||
19
openspec/archive/2026-04-19-enterprise-events/tasks.md
Normal file
19
openspec/archive/2026-04-19-enterprise-events/tasks.md
Normal file
@@ -0,0 +1,19 @@
|
||||
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/events/events.
|
||||
|
||||
# Tasks: enterprise-events
|
||||
|
||||
- [x] 1. Scaffold package.json, tsconfig, vite/vitest configs (thresholds 100), README, CHANGELOG
|
||||
- [x] 2. Implement `errors/codes.ts` (EVENTS_CODES via `defineCodes`) + `events-error.ts` extending `SuaveplanError`
|
||||
- [x] 3. Implement `clock/default-clock.ts` and `id/id-generator.ts` as the single source of non-determinism
|
||||
- [x] 4. Implement `envelope/envelope.ts` + `envelope.schemas.ts` + types
|
||||
- [x] 5. Implement `interceptor/compose.ts` with chain-of-responsibility
|
||||
- [x] 6. Implement `queue/bounded-queue.ts` with all four policies + abort support
|
||||
- [x] 7. Implement `idempotent/idempotent.ts`
|
||||
- [x] 8. Implement `bus/event-bus.ts` composing the pieces
|
||||
- [x] 9. Implement `observability/with-observability.ts`
|
||||
- [x] 10. Cross-cutting guards in `tests/`: dep-graph, no-disallowed, tree-shake
|
||||
- [x] 11. Unit + property-based tests for every module (fast-check)
|
||||
- [x] 12. `bunx turbo lint typecheck test build --filter=@suaveplan/events --force` green
|
||||
- [x] 13. Coverage = 100/100/100/100
|
||||
- [x] 14. `bun scripts/publish.ts --package @suaveplan/events` to Verdaccio at 0.0.0
|
||||
- [x] 15. Archive change folder under `openspec/archive/<yyyy-mm-dd>-enterprise-events/`
|
||||
Reference in New Issue
Block a user