first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,69 @@
# aggregate-repository
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createAggregateRepository`, the primary entry-point for persisting and loading event-sourced aggregates. It bridges the `Aggregate` base class with an `EventStore`, handling snapshot-based optimisation, optimistic concurrency, and automatic snapshotting when a `snapshotThreshold` is configured.
## Features
- `load(aggregateId)` — loads snapshot (if any), then replays only delta events; throws `AGGREGATE_NOT_FOUND` when no events or snapshot exist
- `save(aggregate)` — drains pending events, computes `expectedVersion`, delegates to `store.append`, then optionally saves a new snapshot
- `exists(aggregateId)` — returns true if any events or a snapshot exist for the ID
- `snapshotThreshold` — automatically snapshots when `aggregate.version % threshold === 0`
- OTel spans on `load` and `save` with aggregate ID and event count attributes
- Injectable `EventStore` for full testability
## Usage: Basic Example
```ts
import { createAggregateRepository, createMemoryEventStore } from "@suaveplan/event-sourcing";
const store = createMemoryEventStore();
const repository = createAggregateRepository({
store,
factory: (id) => new OrderAggregate(id),
});
const order = new OrderAggregate("order-1");
order.place(100);
await repository.save(order);
const loaded = await repository.load("order-1");
console.log(loaded.getVersion()); // 1
```
## Usage: Advanced Example
```ts
import { createAggregateRepository, createMemoryEventStore } from "@suaveplan/event-sourcing";
const repository = createAggregateRepository({
store: createMemoryEventStore(),
factory: (id) => new BankAccount(id),
snapshotThreshold: 50,
});
// After 50 events, a snapshot is auto-saved
// On next load, only events after version 50 are replayed
```
## API Reference
### `createAggregateRepository(options)`
- `options.store: EventStore` — backing store implementation
- `options.factory: (id: string) => T` — creates a blank aggregate given an ID
- `options.snapshotThreshold?: number` — snapshot every N events
### `AggregateRepository<T>`
- `load(aggregateId): Promise<T>` — throws `AGGREGATE_NOT_FOUND` if not found
- `save(aggregate: T): Promise<void>` — no-op if no pending events
- `exists(aggregateId): Promise<boolean>`
## Implementation Notes
The `save` method computes `expectedVersion = aggregate.version - pending.length` before clearing pending events. This is the version the store should be at before applying the new events. The snapshot stores the opaque `state` field using internal access — this works because the `Aggregate` base class uses `protected` (not `private`) visibility.

View File

@@ -0,0 +1,103 @@
# Aggregate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Abstract base class for event-sourced aggregates. Provides the foundational lifecycle for raising domain events, replaying historical event streams, and managing the pending-event queue. Subclasses implement only the `applyEvent` method that folds an individual `DomainEvent` onto the current state — the base class handles version tracking, ID generation, and event accumulation.
## Features
- `raise(type, payload, metadata?)` — emits a new domain event, applies it immediately, increments the version, and queues it for persistence
- `replayFrom(events)` — replays a historical event slice (from snapshot version or zero), updating version and state deterministically
- `clearPendingEvents()` — returns and drains the pending queue; called by the repository after successful persistence
- `getPendingEvents()` — read-only snapshot of the pending queue without clearing
- `getVersion()` — exposes current stream version for the repository
- Injectable `Clock` and `IdGenerator` for deterministic tests
- `getAggregateType()` — overridable aggregate type name used in event envelopes
## Usage: Basic Example
```ts
import { Aggregate } from "@suaveplan/event-sourcing";
import type { DomainEvent } from "@suaveplan/event-sourcing";
interface OrderState { total: number; status: string }
class OrderAggregate extends Aggregate<OrderState> {
constructor(id: string) {
super(id, { total: 0, status: "pending" });
}
place(total: number): void {
this.raise("OrderPlaced", { total });
}
protected applyEvent(event: DomainEvent): void {
if (event.type === "OrderPlaced") {
this.state = { ...this.state, total: (event.payload as { total: number }).total, status: "placed" };
}
}
}
const order = new OrderAggregate("order-1");
order.place(250);
console.log(order.getPendingEvents().length); // 1
```
## Usage: Advanced Example
```ts
import { Aggregate } from "@suaveplan/event-sourcing";
class BankAccount extends Aggregate<{ balance: number }> {
constructor(id: string) {
super(id, { balance: 0 });
}
deposit(amount: number): void {
if (amount <= 0) throw new Error("Amount must be positive");
this.raise("Deposited", { amount });
}
withdraw(amount: number): void {
if (amount > this.state.balance) throw new Error("Insufficient funds");
this.raise("Withdrawn", { amount });
}
protected applyEvent(event: DomainEvent): void {
const p = event.payload as { amount: number };
if (event.type === "Deposited") this.state.balance += p.amount;
if (event.type === "Withdrawn") this.state.balance -= p.amount;
}
get balance(): number { return this.state.balance; }
}
```
## API Reference
### `raise(type, payload, metadata?)`
Emits a domain event. Applies it to state immediately via `applyEvent`, then queues in `pendingEvents`.
### `replayFrom(events)`
Replays a sorted slice of events onto the aggregate without touching `pendingEvents`.
### `clearPendingEvents(): DomainEvent[]`
Returns and clears the pending queue. Called by the repository after persist.
### `getPendingEvents(): readonly DomainEvent[]`
Read-only snapshot; does not clear.
### `getVersion(): number`
Current stream version (0 = no events applied).
## Implementation Notes
The base class uses `protected` (not `private`) fields throughout so that test subclasses and framework-level tooling can inspect or extend state without reflection hacks. The `raise` method always applies the event through `applyEvent` before pushing to the pending queue, ensuring the aggregate state is consistent even before persistence.

View File

@@ -0,0 +1,82 @@
# memory-event-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-memory implementation of the `EventStore` interface for use in tests, prototyping, and single-process deployments. Stores event streams in plain `Map` structures, enforces optimistic concurrency via version checking, supports snapshot storage, provides an async generator stream reader, and optionally publishes committed events onto an `EventBusLike` after successful append.
## Features
- Optimistic concurrency: `append` compares the current stream tail version against `expectedVersion` and throws `EventSourcingError(ES.CONCURRENCY_CONFLICT)` on mismatch
- Stream length cap: `maxStreamLength` option prevents unbounded growth during tests
- Snapshot round-trip: `saveSnapshot` / `loadSnapshot` with per-aggregate keyed storage
- Async generator: `getStream(aggregateId)` yields events one by one for streaming replay
- Bus publish-after-commit: when `bus` is provided, each committed event is published as `domain-event:<aggregateType>:<eventType>`
- OTel instrumentation: spans on `append` and `load`, counters for appended events, concurrency conflicts, and snapshots taken, histogram for append and load duration
## Usage: Basic Example
```ts
import { createMemoryEventStore } from "@suaveplan/event-sourcing";
const store = createMemoryEventStore();
await store.append([{
id: "e1", aggregateId: "a1", aggregateType: "Order",
type: "OrderPlaced", version: 1,
payload: { total: 100 }, metadata: {}, occurredAt: new Date()
}], 0);
const events = await store.load("a1");
console.log(events.length); // 1
```
## Usage: Advanced Example
```ts
import { createMemoryEventStore } from "@suaveplan/event-sourcing";
const bus = {
async publish(topic: string, payload: unknown) {
console.log(`Event published: ${topic}`, payload);
}
};
const store = createMemoryEventStore({ bus, maxStreamLength: 10000 });
// Concurrent append detection
await store.append([event1], 0);
try {
await store.append([event2], 0); // version already at 1
} catch (err) {
// EventSourcingError ES.CONCURRENCY_CONFLICT
}
```
## API Reference
### `createMemoryEventStore(options?)`
Factory returning an `EventStore`. Options: `maxStreamLength`, `clock`, `idGenerator`, `bus`, `logger`.
### `append(events, expectedVersion)`
Atomically appends events to the stream. Throws `CONCURRENCY_CONFLICT` if stream version !== `expectedVersion`.
### `load(aggregateId, fromVersion?)`
Returns all events after `fromVersion` (default 0).
### `loadSnapshot(aggregateId)` / `saveSnapshot(snapshot)`
Stores/retrieves a single snapshot per aggregate.
### `getStream(aggregateId)`
Returns an async generator yielding all events for the aggregate.
## Implementation Notes
The in-memory store is not thread-safe — it is designed for single-threaded Node.js / Bun use. For distributed deployments a database-backed adapter should be used. The `append` operation is synchronous under the hood (no real I/O) but presents an async interface so that all `EventStore` implementations are interchangeable.

View File

@@ -0,0 +1,79 @@
# event-sourcing-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error type and stable error-code registry for `@suaveplan/event-sourcing`. All runtime failures from the event store, aggregate repository, projections, and upcasters surface as `EventSourcingError` instances with a stable machine-readable `code` field, enabling callers to discriminate failures without fragile string matching on error messages.
## Features
- `ES_CODES` registry via `@suaveplan/error`'s `defineCodes` — codes are namespaced `ES.*`
- `EventSourcingError` extends `SuaveplanError` for full cause-chain traversal and wire serialisation
- Six stable codes covering concurrency conflicts, missing aggregates, snapshot corruption, missing upcasters, corrupt streams, and append failures
- `EventSourcingCode` union type for exhaustive switch statements
## Usage: Basic Example
```ts
import { EventSourcingError, ES_CODES } from "@suaveplan/event-sourcing";
throw new EventSourcingError(
ES_CODES.CONCURRENCY_CONFLICT,
"Expected version 5 but stream is at version 6",
{ context: { aggregateId: "order-1", expected: 5, actual: 6 } }
);
```
## Usage: Advanced Example
```ts
import { EventSourcingError, ES_CODES } from "@suaveplan/event-sourcing";
async function saveWithRetry(aggregate: OrderAggregate): Promise<void> {
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
await repository.save(aggregate);
return;
} catch (err) {
if (
err instanceof EventSourcingError &&
err.code === ES_CODES.CONCURRENCY_CONFLICT &&
i < maxRetries - 1
) {
// Reload aggregate and retry
aggregate = await repository.load(aggregate.id);
continue;
}
throw err;
}
}
}
```
## API Reference
### `ES_CODES`
Object mapping error names to fully-qualified code strings:
- `ES_CODES.CONCURRENCY_CONFLICT` — optimistic concurrency version mismatch
- `ES_CODES.AGGREGATE_NOT_FOUND` — no events found for an aggregate ID
- `ES_CODES.SNAPSHOT_INVALID` — snapshot failed schema validation
- `ES_CODES.UPCASTER_MISSING` — no upcaster registered for event type + version pair
- `ES_CODES.STREAM_CORRUPT` — events in a stream have non-sequential versions
- `ES_CODES.APPEND_FAILED` — general append failure (wraps underlying cause)
### `EventSourcingError`
- `constructor(code, message, options?)` — `options.cause` wraps the originating error; `options.context` carries structured metadata
### `EventSourcingCode`
Union type of all valid code strings for exhaustive type checking.
## Implementation Notes
`defineCodes` prefixes all names with `"ES."`, producing strings like `"ES.CONCURRENCY_CONFLICT"`. This namespace avoids collisions with other `@suaveplan/*` error registries.

View File

@@ -0,0 +1,72 @@
# projection-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createProjection`, a factory for building declarative read-model projections over sequences of domain events. Projections are the CQRS query side — they accumulate state from an immutable event log without modifying aggregates. The builder pattern keeps projections pure, composable, and testable independently of the event store.
## Features
- `project(events)` — folds a synchronous array of events onto `initialState` using the registered handlers
- `projectStream(stream)` — async variant that consumes an `AsyncIterable<DomainEvent>` for streaming replay
- Unknown event types are silently ignored — projections are forward-compatible with new event types
- Fully typed: `TState` is inferred from `initialState`
- No side effects — projections always start fresh from `initialState`
## Usage: Basic Example
```ts
import { createProjection } from "@suaveplan/event-sourcing";
const orderSummary = createProjection({
initialState: { total: 0, count: 0 },
handlers: {
OrderPlaced: (state, event) => ({
total: state.total + (event.payload as { amount: number }).amount,
count: state.count + 1,
}),
},
});
const result = orderSummary.project(events);
console.log(result.count); // number of placed orders
```
## Usage: Advanced Example
```ts
import { createProjection, createMemoryEventStore } from "@suaveplan/event-sourcing";
const store = createMemoryEventStore();
// Build projection from async stream
const projection = createProjection({
initialState: [] as string[],
handlers: {
UserRegistered: (state, event) => [
...state,
(event.payload as { username: string }).username,
],
},
});
const result = await projection.projectStream(store.getStream("users"));
```
## API Reference
### `createProjection<TState>(options)`
- `options.initialState: TState` — starting state for each projection run
- `options.handlers: Record<string, (state: TState, event: DomainEvent) => TState>` — reducer per event type
### `Projection<TState>`
- `project(events: DomainEvent[]): TState`
- `projectStream(stream: AsyncIterable<DomainEvent>): Promise<TState>`
## Implementation Notes
Each call to `project` or `projectStream` starts from `initialState` — there is no incremental update. For live projections that need incremental updates, callers should maintain external state and call the handler functions directly, or use a stateful wrapper that holds the accumulated state.

View File

@@ -0,0 +1,66 @@
# event-sourcing.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod validation schemas for all public wire-format types in `@suaveplan/event-sourcing`. These schemas are used at integration boundaries — when deserialising events from persistence layers, validating snapshot data, and parsing upcaster registrations — to ensure structural integrity before domain logic runs.
## Features
- `DomainEventSchema` — validates the full event envelope including version, metadata, and typed occurredAt
- `SnapshotSchema` — validates aggregate snapshots before loading into aggregates
- `EventMetadataSchema` — validates event metadata with passthrough for unknown keys
- `MemoryEventStoreOptionsSchema` — validates store construction options
- `UpcasterOptionsSchema` — validates upcaster registration including the migration function
## Usage: Basic Example
```ts
import { DomainEventSchema } from "@suaveplan/event-sourcing";
const raw = JSON.parse(storedJson);
const event = DomainEventSchema.parse({ ...raw, occurredAt: new Date(raw.occurredAt) });
```
## Usage: Advanced Example
```ts
import { SnapshotSchema } from "@suaveplan/event-sourcing";
import z from "zod";
const MyStateSchema = z.object({ balance: z.number() });
const snapshot = SnapshotSchema.extend({
state: MyStateSchema,
}).parse(rawSnapshot);
// snapshot.state is typed as { balance: number }
```
## API Reference
### `DomainEventSchema`
Validates `{ id, aggregateId, aggregateType, type, version, payload, metadata, occurredAt }`. Version must be a positive integer. `occurredAt` must be a `Date`.
### `SnapshotSchema`
Validates `{ aggregateId, aggregateType, version, state, takenAt }`. Version is non-negative integer.
### `EventMetadataSchema`
Validates optional `causationId`, `correlationId`, `actor` and passes through unknown keys.
### `MemoryEventStoreOptionsSchema`
Optional schema; validates `maxStreamLength` as positive integer if present.
### `UpcasterOptionsSchema`
Validates `{ eventType, fromVersion, toVersion, upcast }`. `fromVersion` must be non-negative; `toVersion` must be positive.
## Implementation Notes
All schemas use `.passthrough()` on metadata to avoid stripping custom keys. The `payload` and `state` fields use `z.unknown()` since the concrete shape depends on the event type — callers may extend the schema for stricter validation.

View File

@@ -0,0 +1,79 @@
# event-sourcing.types
## Purpose
Shared TypeScript type definitions for the `@suaveplan/event-sourcing` package. These types form the contract between aggregates, the event store, projections, and the aggregate repository. Centralising them here prevents circular imports and makes the public API surface explicit.
## Features
- `DomainEvent<TPayload>` — the canonical envelope for all domain events, carrying identity, versioning, and metadata
- `Snapshot` — serialised aggregate state at a given stream version for efficient replay
- `EventStore` — the interface that every store implementation must satisfy
- `AggregateRepository<T>` — the typed repository facade
- `MemoryEventStoreOptions` — injectable clock, ID generator, bus, and logger for the in-memory implementation
- `ProjectionOptions<TState>` / `ProjectionHandlers<TState>` — declarative read-model configuration
- `UpcasterOptions` — event schema migration registration
- `EventMetadata` — free-form cross-cutting metadata with typed optional fields
## Usage: Basic Example
```ts
import type { DomainEvent, Snapshot } from "@suaveplan/event-sourcing";
const event: DomainEvent<{ amount: number }> = {
id: "01HXYZ",
aggregateId: "order-1",
aggregateType: "Order",
type: "OrderPlaced",
version: 1,
payload: { amount: 100 },
metadata: { actor: "user-42" },
occurredAt: new Date(),
};
```
## Usage: Advanced Example
```ts
import type {
DomainEvent,
EventStore,
AggregateRepositoryOptions,
} from "@suaveplan/event-sourcing";
// Custom aggregate repository options with snapshot support
const opts: AggregateRepositoryOptions<MyAggregate> = {
store: myEventStore,
factory: (id) => new MyAggregate(id),
snapshotThreshold: 50,
};
```
## API Reference
### `DomainEvent<TPayload>`
- `id: string` — unique event identifier
- `aggregateId: string` — owning aggregate
- `aggregateType: string` — aggregate class discriminator
- `type: string` — event type discriminator
- `version: number` — 1-based sequence within aggregate stream
- `payload: TPayload` — typed event payload
- `metadata: EventMetadata` — cross-cutting metadata
- `occurredAt: Date` — wall-clock timestamp
### `Snapshot`
Point-in-time capture of aggregate state used to skip full replay.
### `EventStore`
Interface with `append`, `load`, `loadSnapshot`, `saveSnapshot`, `getStream`.
### `MemoryEventStoreOptions`
Injectable options for in-memory store: `clock`, `idGenerator`, `bus`, `logger`, `maxStreamLength`.
## Implementation Notes
The types are purely declarative — no runtime code. Using a separate `types` module keeps the aggregate and store implementations free of cross-dependencies.

View File

@@ -0,0 +1,69 @@
# upcaster
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides event schema migration via a registry-based upcaster pattern. When an aggregate's event payloads evolve over time, old events stored in the event store can be transparently migrated to newer schemas on load. Each upcaster migrates from one payload version to the next, forming a chain that `upcastEvents` walks automatically.
## Features
- `registerUpcaster(options)` — adds a migration function for `eventType` at `fromVersion → toVersion`
- `upcastEvents(events, options?)` — walks the upcaster chain for each event, applying all registered migrations
- `clearUpcasters()` — resets the registry (for test isolation)
- `requireComplete` option — throws `UPCASTER_MISSING` if the chain does not terminate for an event type
- Chain limit of 100 steps prevents infinite loops from misconfigured upcasters
## Usage: Basic Example
```ts
import { registerUpcaster, upcastEvents } from "@suaveplan/event-sourcing";
registerUpcaster({
eventType: "OrderPlaced",
fromVersion: 1,
toVersion: 2,
upcast: (payload) => ({
...(payload as object),
currency: "USD", // added in v2
}),
});
const migrated = upcastEvents(oldEvents);
```
## Usage: Advanced Example
```ts
import { registerUpcaster, upcastEvents, clearUpcasters } from "@suaveplan/event-sourcing";
// Chain: v1 -> v2 -> v3
registerUpcaster({ eventType: "OrderPlaced", fromVersion: 1, toVersion: 2, upcast: addCurrency });
registerUpcaster({ eventType: "OrderPlaced", fromVersion: 2, toVersion: 3, upcast: addTaxId });
const events = await store.load("order-1");
const migrated = upcastEvents(events);
// In tests: clean up
clearUpcasters();
```
## API Reference
### `registerUpcaster(options: UpcasterOptions)`
Registers `options.upcast` to migrate payloads from `options.fromVersion` to `options.toVersion` for `options.eventType`.
### `upcastEvents(events, options?)`
- `options.requireComplete` — if `true`, throws `UPCASTER_MISSING` when chain has gaps
- Returns a new array of events with migrated payloads (original events are not mutated)
### `clearUpcasters()`
Resets the global registry. Use in `afterEach` during tests.
## Implementation Notes
The upcaster registry is module-level (global singleton). This matches the typical pattern for event schema registries. The 100-step chain limit prevents runaway loops while supporting realistic migration depths. The `requireComplete` check is a best-effort safeguard and does not guarantee the upcaster graph has no cycles.