# OpenSpec Change: enterprise-event-sourcing - **Change ID**: enterprise-event-sourcing - **Package**: `@suaveplan/event-sourcing` - **Category**: universal - **Target path**: `universal/events/event-sourcing` - **Status**: PROPOSED - **Date**: 2026-04-24 --- ## Summary Deliver `@suaveplan/event-sourcing` on the genesis branch from scratch at enterprise quality. The package provides event-sourcing patterns for building aggregate-driven domain models: a typed `Aggregate` base class with `apply`/`raise` lifecycle, an `EventStore` interface with in-memory and pluggable-adapter implementations, optimistic concurrency via version vectors, a `Projection` builder for read-model construction, snapshotting support, `@suaveplan/diff` delta integration for efficient event payload storage, `@suaveplan/events` integration for publish-after-commit, structured errors, and OTel instrumentation. --- ## Current-state audit (genesis) The package does not exist on genesis yet — fresh build. No prior source, no test results, no coverage data. --- ## Available workspace deps on genesis | Package | Role | |---|---| | `@suaveplan/events` (`workspace:^`) | Publish committed domain events to subscribers after persistence | | `@suaveplan/diff` (`workspace:^`) | JSON Patch delta encoding for efficient event payload storage and upcasting | | `@suaveplan/error` (`workspace:^`) | Base class for `EventSourcingError`; `defineCodes` for `ES.*` codes | | `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `Clock`, `IdGenerator`, `EventBusLike` injection interfaces | | `zod` (`catalog:forms`) | Zod schemas for event envelope, snapshot, and options types | | `@opentelemetry/api` (`catalog:observability`) | Spans, counters, histograms on append, load, project operations | | `tslib` (`catalog:build`) | Runtime helpers | Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`). --- ## Public API surface ### Domain event ```ts interface DomainEvent { id: string aggregateId: string aggregateType: string type: string version: number // sequence number within the aggregate stream payload: TPayload metadata: EventMetadata occurredAt: Date } interface EventMetadata { causationId?: string correlationId?: string actor?: string [key: string]: unknown } ``` ### Aggregate base class ```ts abstract class Aggregate { readonly id: string protected state: TState readonly version: number // Raise a domain event — applies it immediately and queues it for commit protected raise(type: string, payload: TPayload): void // Apply a domain event to internal state (called by replay too) protected abstract apply(event: DomainEvent): void // Pending events not yet committed readonly pendingEvents: DomainEvent[] // Replay a sequence of historical events to rebuild state replayFrom(events: DomainEvent[]): void // Mark pending events as committed (called by EventStore after write) clearPendingEvents(): void } ``` ### EventStore interface ```ts interface EventStore { append(events: DomainEvent[], expectedVersion: number): Promise load(aggregateId: string, fromVersion?: number): Promise loadSnapshot(aggregateId: string): Promise saveSnapshot(snapshot: Snapshot): Promise getStream(aggregateId: string): AsyncGenerator } interface Snapshot { aggregateId: string aggregateType: string version: number state: unknown takenAt: Date } ``` ### In-memory event store ```ts function createMemoryEventStore(options?: MemoryEventStoreOptions): EventStore interface MemoryEventStoreOptions { maxStreamLength?: number clock?: Clock idGenerator?: IdGenerator bus?: EventBusLike // publishes committed events to subscribers logger?: LoggerLike } ``` ### Aggregate repository ```ts // Combines EventStore with Aggregate reconstruction function createAggregateRepository>( options: AggregateRepositoryOptions ): AggregateRepository interface AggregateRepository> { load(aggregateId: string): Promise save(aggregate: T): Promise exists(aggregateId: string): Promise } interface AggregateRepositoryOptions> { store: EventStore factory: (id: string) => T snapshotThreshold?: number // take snapshot every N events; default: disabled } ``` ### Projection builder ```ts function createProjection(options: ProjectionOptions): Projection interface ProjectionOptions { initialState: TState handlers: ProjectionHandlers } type ProjectionHandlers = Record< string, (state: TState, event: DomainEvent) => TState > interface Projection { project(events: DomainEvent[]): TState projectStream(stream: AsyncIterable): Promise } ``` ### Upcasting (event schema migration) ```ts // Register an upcaster that migrates payloads from old event versions function registerUpcaster(options: UpcasterOptions): void interface UpcasterOptions { eventType: string fromVersion: number toVersion: number upcast: (payload: unknown) => unknown } // Upcast a sequence of events to the latest schema version function upcastEvents(events: DomainEvent[]): DomainEvent[] ``` ### Error handling ```ts class EventSourcingError extends SuaveplanError type EventSourcingErrorCode = | "ES_CONCURRENCY_CONFLICT" | "ES_AGGREGATE_NOT_FOUND" | "ES_SNAPSHOT_INVALID" | "ES_UPCASTER_MISSING" | "ES_STREAM_CORRUPT" | "ES_APPEND_FAILED" function registerEventSourcingErrorCodes(): void ``` ### Zod schemas ```ts const DomainEventSchema: ZodSchema const SnapshotSchema: ZodSchema const MemoryEventStoreOptionsSchema: ZodSchema const AggregateRepositoryOptionsSchema: ZodSchema ``` --- ## New additions for genesis ### 1. Optimistic concurrency `EventStore.append` takes `expectedVersion`. If the current stream version does not match, it throws `EventSourcingError("ES_CONCURRENCY_CONFLICT")`. The `AggregateRepository` passes `aggregate.version` as the expected version, retrying the load-modify-save cycle up to the caller-configured `maxRetries`. ### 2. Snapshot-based reconstruction When `snapshotThreshold` is set, `AggregateRepository.save` automatically saves a snapshot when `aggregate.version % snapshotThreshold === 0`. On `load`, the repository loads the latest snapshot first, then replays only events after the snapshot version. ### 3. `@suaveplan/diff` delta payloads The in-memory store optionally stores event payloads as JSON Patch deltas against the previous event in the same stream (using `@suaveplan/diff`). `EventStore.load` transparently upcasts deltas back to full payloads. This is an opt-in compression mode controlled by `MemoryEventStoreOptions.deltaPayloads`. ### 4. `@suaveplan/events` publish-after-commit When `bus` is provided in `MemoryEventStoreOptions`, the store emits each committed `DomainEvent` onto the bus as `domain-event::` after a successful `append`. This enables subscriptions without polling the store. ### 5. OTel instrumentation - `store.append` — span `es.append`, attributes: `es.aggregate_id`, `es.aggregate_type`, `es.event_count`, `es.version` - `store.load` — span `es.load`, attributes: `es.aggregate_id`, `es.event_count` - `repository.save` — span `es.repository.save`, includes optimistic concurrency retry count - Counters: `es.events_appended_total`, `es.concurrency_conflicts_total`, `es.snapshots_taken_total` - Histogram: `es.append_duration_ms`, `es.load_duration_ms` ### 6. Property-based tests (fast-check) - Replay determinism: replaying the same event sequence always produces the same aggregate state - Optimistic concurrency: concurrent appends with the same expected version produce exactly one success - Projection completeness: projecting all events returns the same state as building incrementally - Snapshot round-trip: saving and loading a snapshot yields identical aggregate state ### 7. Benchmark suite (`src/__benchmarks__/`) - Load aggregate with 100, 1k, 10k events (no snapshot) - Load aggregate with 10k events and snapshot at 500 - Append 1k events to a single stream --- ## File layout ```text universal/events/event-sourcing/ package.json tsconfig.json vitest.config.ts src/ index.ts error-codes.ts constants/ index.ts types/ common.ts common.md core/ aggregate/ aggregate.ts aggregate.md aggregate.types.ts aggregate.test.ts index.ts event-store/ event-store.ts event-store.md event-store.types.ts event-store.test.ts index.ts memory-event-store/ memory-event-store.ts memory-event-store.md memory-event-store.types.ts memory-event-store.test.ts index.ts aggregate-repository/ aggregate-repository.ts aggregate-repository.md aggregate-repository.types.ts aggregate-repository.test.ts index.ts projection/ projection-builder/ projection-builder.ts projection-builder.md projection-builder.types.ts projection-builder.test.ts index.ts upcasting/ upcaster/ upcaster.ts upcaster.md upcaster.types.ts upcaster.test.ts index.ts __benchmarks__/ event-sourcing.bench.ts tests/ integration.test.ts property.test.ts ``` --- ## Impact **Downstream packages**: any domain-model package following event-sourcing architecture. Enables CQRS read-model construction via projections and cross-context publishing via `@suaveplan/events`. **Workspace dep prerequisites**: `@suaveplan/events`, `@suaveplan/diff`, `@suaveplan/error`, `@suaveplan/types` must be published to Verdaccio. **Breaking changes**: none (new package).