320 lines
9.8 KiB
Markdown
320 lines
9.8 KiB
Markdown
# 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<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
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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
|
|
|
|
```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<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
|
|
|
|
```ts
|
|
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)
|
|
|
|
```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<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
|
|
|
|
```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:<aggregateType>:<eventType>` 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).
|