Initial Commit
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,99 @@
|
||||
# Tasks: enterprise-event-sourcing
|
||||
|
||||
- **Change ID**: enterprise-event-sourcing
|
||||
- **Package**: `@suaveplan/event-sourcing`
|
||||
- **Date**: 2026-04-24
|
||||
|
||||
## Prerequisites (must be published to Verdaccio first)
|
||||
|
||||
- [x] `@suaveplan/events`
|
||||
- [x] `@suaveplan/diff`
|
||||
- [x] `@suaveplan/error`
|
||||
- [x] `@suaveplan/types`
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Scaffold package
|
||||
|
||||
- [x] Create `universal/events/event-sourcing/` directory tree per file layout in `proposal.md`
|
||||
- [x] Write `package.json` (name `@suaveplan/event-sourcing`, version `0.1.0`, `sideEffects: false`, correct workspace deps and catalog externals)
|
||||
- [x] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`
|
||||
- [x] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`
|
||||
|
||||
### 2. Error codes (`src/error-codes.ts`)
|
||||
|
||||
- [x] Define `EventSourcingErrorCode` union type
|
||||
- [x] Call `defineCodes` to register all `ES.*` codes
|
||||
- [x] Export `registerEventSourcingErrorCodes()` and `EventSourcingError`
|
||||
|
||||
### 3. Types (`src/types/common.ts`)
|
||||
|
||||
- [x] Define and export `DomainEvent<T>`, `EventMetadata`, `Snapshot`, `EventStore`, `AggregateRepositoryOptions`
|
||||
- [x] Write `common.md`
|
||||
|
||||
### 4. `Aggregate` base class (`src/core/aggregate/`)
|
||||
|
||||
- [x] Implement abstract `Aggregate<TState>` with `raise`, `apply` (abstract), `replayFrom`, `clearPendingEvents`, `pendingEvents`, `version`, `id`
|
||||
- [x] Write `.md`, `.types.ts`, `.test.ts`, `index.ts`
|
||||
|
||||
### 5. `EventStore` interface (`src/core/event-store/`)
|
||||
|
||||
- [x] Define and export `EventStore` interface with `append`, `load`, `loadSnapshot`, `saveSnapshot`, `getStream`
|
||||
- [x] Write `.md`, `.types.ts`, `index.ts`
|
||||
|
||||
### 6. In-memory event store (`src/core/memory-event-store/`)
|
||||
|
||||
- [x] Implement `createMemoryEventStore` with:
|
||||
- Per-aggregate stream storage (Map of arrays)
|
||||
- Optimistic concurrency: `append` checks `expectedVersion` and throws `ES_CONCURRENCY_CONFLICT` on mismatch
|
||||
- Snapshot storage alongside event streams
|
||||
- Publish committed events to `@suaveplan/events` bus after successful `append`
|
||||
- Optional delta-payload compression via `@suaveplan/diff` when `deltaPayloads: true`
|
||||
- OTel spans, counters, histograms per `proposal.md` §5
|
||||
- [x] Write `.md`, `.types.ts`, `.test.ts`, `index.ts`
|
||||
|
||||
### 7. Aggregate repository (`src/core/aggregate-repository/`)
|
||||
|
||||
- [x] Implement `createAggregateRepository<T>` with snapshot-based reconstruction
|
||||
- [x] Optimistic concurrency retry loop (configurable `maxRetries`)
|
||||
- [x] Auto-snapshot at configurable `snapshotThreshold`
|
||||
- [x] Write `.md`, `.types.ts`, `.test.ts`, `index.ts`
|
||||
|
||||
### 8. Projection builder (`src/projection/projection-builder/`)
|
||||
|
||||
- [x] Implement `createProjection<TState>` with `project(events)` and `projectStream(stream)` methods
|
||||
- [x] Write `.md`, `.types.ts`, `.test.ts`, `index.ts`
|
||||
|
||||
### 9. Upcaster registry (`src/upcasting/upcaster/`)
|
||||
|
||||
- [x] Implement `registerUpcaster` and `upcastEvents` with version-ordered upcaster chain
|
||||
- [x] Write `.md`, `.types.ts`, `.test.ts`, `index.ts`
|
||||
|
||||
### 10. Tests
|
||||
|
||||
- [x] Unit tests co-located (100% coverage target)
|
||||
- [x] `tests/integration.test.ts` — full aggregate lifecycle: create, load, save with snapshot, reload from snapshot + trailing events
|
||||
- [x] `tests/property.test.ts` — fast-check properties per `proposal.md` §6
|
||||
|
||||
### 11. Benchmarks (`src/__benchmarks__/event-sourcing.bench.ts`)
|
||||
|
||||
- [x] Implement benchmark cases per `proposal.md` §7
|
||||
|
||||
### 12. Docs
|
||||
|
||||
- [x] Every `.ts` implementation file has a sibling `.md`
|
||||
- [x] Root `README.md`
|
||||
|
||||
### 13. Pre-publish gate
|
||||
|
||||
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/event-sourcing` green
|
||||
- [x] `bunx turbo test:coverage --filter=@suaveplan/event-sourcing` ≥ 100% on touched modules
|
||||
|
||||
### 14. Publish
|
||||
|
||||
- [x] `bun scripts/publish.ts --package @suaveplan/event-sourcing`
|
||||
|
||||
### 15. Archive
|
||||
|
||||
- [x] Fold spec delta into `openspec/specs/events/spec.md`
|
||||
- [x] `mv openspec/changes/enterprise-event-sourcing openspec/archive/2026-04-24-enterprise-event-sourcing`
|
||||
Reference in New Issue
Block a user