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
|
||||
Reference in New Issue
Block a user