9.8 KiB
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
interface DomainEvent<TPayload = unknown> {
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
abstract class Aggregate<TState> {
readonly id: string
protected state: TState
readonly version: number
// Raise a domain event — applies it immediately and queues it for commit
protected raise<TPayload>(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
interface EventStore {
append(events: DomainEvent[], expectedVersion: number): Promise<void>
load(aggregateId: string, fromVersion?: number): Promise<DomainEvent[]>
loadSnapshot(aggregateId: string): Promise<Snapshot | undefined>
saveSnapshot(snapshot: Snapshot): Promise<void>
getStream(aggregateId: string): AsyncGenerator<DomainEvent>
}
interface Snapshot {
aggregateId: string
aggregateType: string
version: number
state: unknown
takenAt: Date
}
In-memory event store
function createMemoryEventStore(options?: MemoryEventStoreOptions): EventStore
interface MemoryEventStoreOptions {
maxStreamLength?: number
clock?: Clock
idGenerator?: IdGenerator
bus?: EventBusLike // publishes committed events to subscribers
logger?: LoggerLike
}
Aggregate repository
// Combines EventStore with Aggregate reconstruction
function createAggregateRepository<T extends Aggregate<unknown>>(
options: AggregateRepositoryOptions<T>
): AggregateRepository<T>
interface AggregateRepository<T extends Aggregate<unknown>> {
load(aggregateId: string): Promise<T>
save(aggregate: T): Promise<void>
exists(aggregateId: string): Promise<boolean>
}
interface AggregateRepositoryOptions<T extends Aggregate<unknown>> {
store: EventStore
factory: (id: string) => T
snapshotThreshold?: number // take snapshot every N events; default: disabled
}
Projection builder
function createProjection<TState>(options: ProjectionOptions<TState>): Projection<TState>
interface ProjectionOptions<TState> {
initialState: TState
handlers: ProjectionHandlers<TState>
}
type ProjectionHandlers<TState> = Record<
string,
(state: TState, event: DomainEvent) => TState
>
interface Projection<TState> {
project(events: DomainEvent[]): TState
projectStream(stream: AsyncIterable<DomainEvent>): Promise<TState>
}
Upcasting (event schema migration)
// 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
class EventSourcingError extends SuaveplanError<EventSourcingErrorCode>
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
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:<aggregateType>:<eventType> after a successful append. This enables subscriptions without polling the store.
5. OTel instrumentation
store.append— spanes.append, attributes:es.aggregate_id,es.aggregate_type,es.event_count,es.versionstore.load— spanes.load, attributes:es.aggregate_id,es.event_countrepository.save— spanes.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
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).