Initial Commit
This commit is contained in:
67
packages/networking/events/event-sourcing/README.md
Normal file
67
packages/networking/events/event-sourcing/README.md
Normal file
File diff suppressed because one or more lines are too long
81
packages/networking/events/event-sourcing/package.json
Normal file
81
packages/networking/events/event-sourcing/package.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "@suaveplan/event-sourcing",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Event sourcing patterns: aggregate root, typed event store, snapshot support, projections, upcasting, and OTel instrumentation",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./errors/event-sourcing-error": {
|
||||
"import": { "types": "./dist/errors/event-sourcing-error.d.ts", "default": "./dist/errors/event-sourcing-error.js" },
|
||||
"require": { "types": "./dist/errors/event-sourcing-error.d.ts", "default": "./dist/errors/event-sourcing-error.js" }
|
||||
},
|
||||
"./core/aggregate/aggregate": {
|
||||
"import": { "types": "./dist/core/aggregate/aggregate.d.ts", "default": "./dist/core/aggregate/aggregate.js" },
|
||||
"require": { "types": "./dist/core/aggregate/aggregate.d.ts", "default": "./dist/core/aggregate/aggregate.js" }
|
||||
},
|
||||
"./core/event-store/memory-event-store": {
|
||||
"import": { "types": "./dist/core/event-store/memory-event-store.d.ts", "default": "./dist/core/event-store/memory-event-store.js" },
|
||||
"require": { "types": "./dist/core/event-store/memory-event-store.d.ts", "default": "./dist/core/event-store/memory-event-store.js" }
|
||||
},
|
||||
"./core/aggregate-repository/aggregate-repository": {
|
||||
"import": { "types": "./dist/core/aggregate-repository/aggregate-repository.d.ts", "default": "./dist/core/aggregate-repository/aggregate-repository.js" },
|
||||
"require": { "types": "./dist/core/aggregate-repository/aggregate-repository.d.ts", "default": "./dist/core/aggregate-repository/aggregate-repository.js" }
|
||||
},
|
||||
"./projection/builder/builder": {
|
||||
"import": { "types": "./dist/projection/builder/builder.d.ts", "default": "./dist/projection/builder/builder.js" },
|
||||
"require": { "types": "./dist/projection/builder/builder.d.ts", "default": "./dist/projection/builder/builder.js" }
|
||||
},
|
||||
"./upcasting/upcaster/upcaster": {
|
||||
"import": { "types": "./dist/upcasting/upcaster/upcaster.d.ts", "default": "./dist/upcasting/upcaster/upcaster.js" },
|
||||
"require": { "types": "./dist/upcasting/upcaster/upcaster.d.ts", "default": "./dist/upcasting/upcaster/upcaster.js" }
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"bench": "bun run src/__benchmarks__/event-sourcing.bench.ts",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/diff": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Default snapshot threshold (disabled by default). */
|
||||
export const DEFAULT_SNAPSHOT_THRESHOLD = undefined;
|
||||
|
||||
/** Maximum upcaster chain steps before hard stop. */
|
||||
export const MAX_UPCAST_CHAIN_STEPS = 100;
|
||||
@@ -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.
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ES_CODES,
|
||||
EventSourcingError,
|
||||
} from "../../errors/event-sourcing-error.js";
|
||||
import type { DomainEvent } from "../../types/event-sourcing.types.js";
|
||||
import { Aggregate } from "../aggregate/aggregate.js";
|
||||
import { createMemoryEventStore } from "../event-store/memory-event-store.js";
|
||||
import { createAggregateRepository } from "./aggregate-repository.js";
|
||||
|
||||
class TestAggregate extends Aggregate<{ value: number }> {
|
||||
public constructor(id: string) {
|
||||
super(id, { value: 0 });
|
||||
}
|
||||
|
||||
public setValue(v: number): void {
|
||||
this.raise("ValueSet", { v });
|
||||
}
|
||||
|
||||
protected applyEvent(event: DomainEvent): void {
|
||||
if (event.type === "ValueSet") {
|
||||
this.state = { value: (event.payload as { v: number }).v };
|
||||
}
|
||||
}
|
||||
|
||||
public getValue(): number {
|
||||
return this.state.value;
|
||||
}
|
||||
}
|
||||
|
||||
describe("createAggregateRepository", () => {
|
||||
it("saves and loads aggregate", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new TestAggregate(id),
|
||||
});
|
||||
const agg = new TestAggregate("a1");
|
||||
agg.setValue(42);
|
||||
await repo.save(agg);
|
||||
const loaded = await repo.load("a1");
|
||||
expect(loaded.getValue()).toBe(42);
|
||||
});
|
||||
|
||||
it("throws AGGREGATE_NOT_FOUND for unknown id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new TestAggregate(id),
|
||||
});
|
||||
await expect(repo.load("unknown")).rejects.toThrow(EventSourcingError);
|
||||
const err = await repo.load("unknown").catch((e: unknown) => e);
|
||||
expect((err as EventSourcingError).code).toBe(
|
||||
ES_CODES.AGGREGATE_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("exists returns false for unknown", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new TestAggregate(id),
|
||||
});
|
||||
expect(await repo.exists("unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("exists returns true after save", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new TestAggregate(id),
|
||||
});
|
||||
const agg = new TestAggregate("a1");
|
||||
agg.setValue(1);
|
||||
await repo.save(agg);
|
||||
expect(await repo.exists("a1")).toBe(true);
|
||||
});
|
||||
|
||||
it("save is no-op when no pending events", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new TestAggregate(id),
|
||||
});
|
||||
const agg = new TestAggregate("a1");
|
||||
// No events raised — save should not throw
|
||||
await expect(repo.save(agg)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("auto-snapshots at threshold", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new TestAggregate(id),
|
||||
snapshotThreshold: 2,
|
||||
});
|
||||
const agg = new TestAggregate("a1");
|
||||
agg.setValue(1);
|
||||
agg.setValue(2);
|
||||
await repo.save(agg);
|
||||
// Snapshot should have been saved at version 2
|
||||
const snap = await store.loadSnapshot("a1");
|
||||
expect(snap).toBeDefined();
|
||||
expect(snap?.version).toBe(2);
|
||||
});
|
||||
|
||||
it("loads from snapshot correctly", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new TestAggregate(id),
|
||||
snapshotThreshold: 2,
|
||||
});
|
||||
const agg = new TestAggregate("a1");
|
||||
agg.setValue(10);
|
||||
agg.setValue(20);
|
||||
await repo.save(agg);
|
||||
|
||||
// Raise more events
|
||||
const agg2 = await repo.load("a1");
|
||||
agg2.setValue(30);
|
||||
await repo.save(agg2);
|
||||
|
||||
const loaded = await repo.load("a1");
|
||||
expect(loaded.getValue()).toBe(30);
|
||||
expect(loaded.getVersion()).toBe(3);
|
||||
});
|
||||
|
||||
it("exists returns true when only snapshot exists", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new TestAggregate(id),
|
||||
});
|
||||
await store.saveSnapshot({
|
||||
aggregateId: "a1",
|
||||
aggregateType: "TestAggregate",
|
||||
version: 5,
|
||||
state: { value: 99 },
|
||||
takenAt: new Date(),
|
||||
});
|
||||
expect(await repo.exists("a1")).toBe(true);
|
||||
});
|
||||
|
||||
it("load with snapshot only does not throw", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new TestAggregate(id),
|
||||
});
|
||||
await store.saveSnapshot({
|
||||
aggregateId: "a1",
|
||||
aggregateType: "TestAggregate",
|
||||
version: 5,
|
||||
state: { value: 99 },
|
||||
takenAt: new Date(),
|
||||
});
|
||||
const loaded = await repo.load("a1");
|
||||
expect(loaded.getValue()).toBe(99);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import {
|
||||
ES_CODES,
|
||||
EventSourcingError,
|
||||
} from "../../errors/event-sourcing-error.js";
|
||||
import type {
|
||||
AggregateRepository,
|
||||
AggregateRepositoryOptions,
|
||||
EventStore,
|
||||
Snapshot,
|
||||
} from "../../types/event-sourcing.types.js";
|
||||
import type { Aggregate } from "../aggregate/aggregate.js";
|
||||
|
||||
const tracer = trace.getTracer("@suaveplan/event-sourcing");
|
||||
|
||||
/**
|
||||
* Implementation of `AggregateRepository` that orchestrates load-from-store
|
||||
* and persist-to-store operations with optional snapshot support.
|
||||
*/
|
||||
class AggregateRepositoryImpl<T extends Aggregate<unknown>>
|
||||
implements AggregateRepository<T>
|
||||
{
|
||||
protected readonly store: EventStore;
|
||||
protected readonly factory: (id: string) => T;
|
||||
protected readonly snapshotThreshold: number | undefined;
|
||||
|
||||
public constructor(options: AggregateRepositoryOptions<T>) {
|
||||
this.store = options.store;
|
||||
this.factory = options.factory;
|
||||
this.snapshotThreshold = options.snapshotThreshold;
|
||||
}
|
||||
|
||||
public async load(aggregateId: string): Promise<T> {
|
||||
const span = tracer.startSpan("es.repository.load");
|
||||
try {
|
||||
const aggregate = this.factory(aggregateId);
|
||||
|
||||
const snapshot = await this.store.loadSnapshot(aggregateId);
|
||||
const fromVersion = snapshot ? snapshot.version : 0;
|
||||
|
||||
if (snapshot) {
|
||||
// Restore state from snapshot
|
||||
(aggregate as unknown as { state: unknown }).state =
|
||||
snapshot.state;
|
||||
(aggregate as unknown as { version: number }).version =
|
||||
snapshot.version;
|
||||
}
|
||||
|
||||
const events = await this.store.load(aggregateId, fromVersion);
|
||||
|
||||
if (events.length === 0 && !snapshot) {
|
||||
throw new EventSourcingError(
|
||||
ES_CODES.AGGREGATE_NOT_FOUND,
|
||||
`Aggregate "${aggregateId}" not found`,
|
||||
{ context: { aggregateId } }
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length > 0) {
|
||||
aggregate.replayFrom(events);
|
||||
}
|
||||
|
||||
span.setAttribute("es.aggregate_id", aggregateId);
|
||||
return aggregate;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
public async save(aggregate: T): Promise<void> {
|
||||
const span = tracer.startSpan("es.repository.save");
|
||||
try {
|
||||
const pending = aggregate.clearPendingEvents();
|
||||
if (pending.length === 0) return;
|
||||
|
||||
const expectedVersion = aggregate.getVersion() - pending.length;
|
||||
await this.store.append(pending, expectedVersion);
|
||||
|
||||
span.setAttribute("es.aggregate_id", aggregate.id);
|
||||
span.setAttribute("es.event_count", pending.length);
|
||||
|
||||
if (
|
||||
this.snapshotThreshold !== undefined &&
|
||||
aggregate.getVersion() % this.snapshotThreshold === 0
|
||||
) {
|
||||
const snapshot: Snapshot = {
|
||||
aggregateId: aggregate.id,
|
||||
aggregateType: aggregate.constructor.name,
|
||||
version: aggregate.getVersion(),
|
||||
state: (aggregate as unknown as { state: unknown }).state,
|
||||
takenAt: new Date(),
|
||||
};
|
||||
await this.store.saveSnapshot(snapshot);
|
||||
}
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
public async exists(aggregateId: string): Promise<boolean> {
|
||||
const events = await this.store.load(aggregateId);
|
||||
const snapshot = await this.store.loadSnapshot(aggregateId);
|
||||
return events.length > 0 || snapshot !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an `AggregateRepository` for the given aggregate type.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const repository = createAggregateRepository({
|
||||
* store,
|
||||
* factory: (id) => new OrderAggregate(id),
|
||||
* snapshotThreshold: 50,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createAggregateRepository<T extends Aggregate<unknown>>(
|
||||
options: AggregateRepositoryOptions<T>
|
||||
): AggregateRepository<T> {
|
||||
return new AggregateRepositoryImpl(options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createAggregateRepository } from "./aggregate-repository.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DomainEvent } from "../../types/event-sourcing.types.js";
|
||||
import { Aggregate } from "./aggregate.js";
|
||||
|
||||
interface CounterState {
|
||||
count: number;
|
||||
}
|
||||
|
||||
class CounterAggregate extends Aggregate<CounterState> {
|
||||
public constructor(id: string) {
|
||||
super(id, { count: 0 });
|
||||
}
|
||||
|
||||
public increment(by = 1): void {
|
||||
this.raise("Incremented", { by });
|
||||
}
|
||||
|
||||
public decrement(by = 1): void {
|
||||
this.raise("Decremented", { by });
|
||||
}
|
||||
|
||||
protected applyEvent(event: DomainEvent): void {
|
||||
const payload = event.payload as { by: number };
|
||||
if (event.type === "Incremented") {
|
||||
this.state = { count: this.state.count + payload.by };
|
||||
} else if (event.type === "Decremented") {
|
||||
this.state = { count: this.state.count - payload.by };
|
||||
}
|
||||
}
|
||||
|
||||
public getCount(): number {
|
||||
return this.state.count;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Aggregate", () => {
|
||||
it("starts at version 0", { timeout: 5000 }, () => {
|
||||
const a = new CounterAggregate("c1");
|
||||
expect(a.getVersion()).toBe(0);
|
||||
});
|
||||
|
||||
it("increments version on raise", { timeout: 5000 }, () => {
|
||||
const a = new CounterAggregate("c1");
|
||||
a.increment();
|
||||
expect(a.getVersion()).toBe(1);
|
||||
a.increment();
|
||||
expect(a.getVersion()).toBe(2);
|
||||
});
|
||||
|
||||
it("applies event immediately", { timeout: 5000 }, () => {
|
||||
const a = new CounterAggregate("c1");
|
||||
a.increment(5);
|
||||
expect(a.getCount()).toBe(5);
|
||||
});
|
||||
|
||||
it("queues pending events", { timeout: 5000 }, () => {
|
||||
const a = new CounterAggregate("c1");
|
||||
a.increment();
|
||||
a.increment();
|
||||
expect(a.getPendingEvents()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("clearPendingEvents returns and clears queue", { timeout: 5000 }, () => {
|
||||
const a = new CounterAggregate("c1");
|
||||
a.increment();
|
||||
const events = a.clearPendingEvents();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(a.getPendingEvents()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("replayFrom rebuilds state", { timeout: 5000 }, () => {
|
||||
const a = new CounterAggregate("c1");
|
||||
const events: DomainEvent[] = [
|
||||
{
|
||||
id: "e1",
|
||||
aggregateId: "c1",
|
||||
aggregateType: "CounterAggregate",
|
||||
type: "Incremented",
|
||||
version: 1,
|
||||
payload: { by: 3 },
|
||||
metadata: {},
|
||||
occurredAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "e2",
|
||||
aggregateId: "c1",
|
||||
aggregateType: "CounterAggregate",
|
||||
type: "Incremented",
|
||||
version: 2,
|
||||
payload: { by: 7 },
|
||||
metadata: {},
|
||||
occurredAt: new Date(),
|
||||
},
|
||||
];
|
||||
a.replayFrom(events);
|
||||
expect(a.getCount()).toBe(10);
|
||||
expect(a.getVersion()).toBe(2);
|
||||
expect(a.getPendingEvents()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("raised events carry correct fields", { timeout: 5000 }, () => {
|
||||
const a = new CounterAggregate("c1");
|
||||
a.increment(2);
|
||||
const [event] = a.getPendingEvents();
|
||||
expect(event).toBeDefined();
|
||||
expect(event?.aggregateId).toBe("c1");
|
||||
expect(event?.aggregateType).toBe("CounterAggregate");
|
||||
expect(event?.type).toBe("Incremented");
|
||||
expect(event?.version).toBe(1);
|
||||
expect((event?.payload as { by: number }).by).toBe(2);
|
||||
});
|
||||
|
||||
it("injectable clock used for occurredAt", { timeout: 5000 }, () => {
|
||||
const fixedMs = new Date("2024-01-01T00:00:00Z").getTime();
|
||||
const c = new (class extends Aggregate<{ count: number }> {
|
||||
public constructor() {
|
||||
super("cx", { count: 0 }, { clock: () => fixedMs });
|
||||
}
|
||||
public increment(): void {
|
||||
this.raise("Inc", { by: 1 });
|
||||
}
|
||||
protected applyEvent(): void {}
|
||||
})();
|
||||
c.increment();
|
||||
expect(c.getPendingEvents()[0]?.occurredAt).toEqual(new Date(fixedMs));
|
||||
});
|
||||
|
||||
it("getAggregateType returns class name", { timeout: 5000 }, () => {
|
||||
const a = new CounterAggregate("c1");
|
||||
a.increment();
|
||||
expect(a.getPendingEvents()[0]?.aggregateType).toBe("CounterAggregate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import type { DomainEvent } from "../../types/event-sourcing.types.js";
|
||||
|
||||
/**
|
||||
* Abstract base class for event-sourced aggregates.
|
||||
*
|
||||
* Subclasses implement `apply` to fold domain events onto state, and call
|
||||
* `this.raise(type, payload)` to emit new events. The base class tracks
|
||||
* the current stream version and accumulates pending events ready to be
|
||||
* committed to an `EventStore`.
|
||||
*/
|
||||
export abstract class Aggregate<TState> {
|
||||
/**
|
||||
* Unique aggregate identifier — set via the constructor and immutable.
|
||||
*/
|
||||
public readonly id: string;
|
||||
|
||||
/**
|
||||
* Current aggregate state. Updated on every event applied.
|
||||
*/
|
||||
protected state: TState;
|
||||
|
||||
/**
|
||||
* Current stream version. 0 means no events have been applied.
|
||||
*/
|
||||
protected version = 0;
|
||||
|
||||
/**
|
||||
* Events raised since last commit. Cleared by the repository after
|
||||
* successful persistence.
|
||||
*/
|
||||
protected pendingEvents: DomainEvent[] = [];
|
||||
|
||||
protected readonly clock: Clock;
|
||||
protected readonly idGenerator: IdGenerator;
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
initialState: TState,
|
||||
deps?: { clock?: Clock; idGenerator?: IdGenerator }
|
||||
) {
|
||||
this.id = id;
|
||||
this.state = initialState;
|
||||
this.clock = deps?.clock ?? (() => Date.now());
|
||||
this.idGenerator = deps?.idGenerator ?? (() => crypto.randomUUID());
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a domain event to aggregate state. Called for both historical
|
||||
* events during replay and newly raised events.
|
||||
*/
|
||||
protected abstract applyEvent(event: DomainEvent): void;
|
||||
|
||||
/**
|
||||
* Returns the pending events and clears the internal queue.
|
||||
*/
|
||||
public clearPendingEvents(): DomainEvent[] {
|
||||
const events = [...this.pendingEvents];
|
||||
this.pendingEvents = [];
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the current version for external consumers (read-only).
|
||||
*/
|
||||
public getVersion(): number {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose pending events without clearing (read-only snapshot).
|
||||
*/
|
||||
public getPendingEvents(): readonly DomainEvent[] {
|
||||
return this.pendingEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a sequence of events to rebuild state from scratch or from a
|
||||
* snapshot. Events must be in ascending version order.
|
||||
*/
|
||||
public replayFrom(events: DomainEvent[]): void {
|
||||
for (const event of events) {
|
||||
this.applyEvent(event);
|
||||
this.version = event.version;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise a new domain event. The event is applied immediately to state
|
||||
* and queued in `pendingEvents`.
|
||||
*
|
||||
* @param type - The event type discriminator.
|
||||
* @param payload - Strongly-typed event payload.
|
||||
* @param metadata - Optional cross-cutting metadata.
|
||||
*/
|
||||
protected raise(
|
||||
type: string,
|
||||
payload: unknown,
|
||||
metadata: Record<string, unknown> = {}
|
||||
): void {
|
||||
const event: DomainEvent = {
|
||||
id: this.idGenerator(),
|
||||
aggregateId: this.id,
|
||||
aggregateType: this.getAggregateType(),
|
||||
type,
|
||||
version: this.version + 1,
|
||||
payload,
|
||||
metadata,
|
||||
occurredAt: new Date(this.clock()),
|
||||
};
|
||||
this.applyEvent(event);
|
||||
this.version = event.version;
|
||||
this.pendingEvents.push(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the aggregate type name used in domain events.
|
||||
* Defaults to the class name; override for explicit naming.
|
||||
*/
|
||||
protected getAggregateType(): string {
|
||||
return this.constructor.name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Aggregate } from "./aggregate.js";
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ES_CODES,
|
||||
EventSourcingError,
|
||||
} from "../../errors/event-sourcing-error.js";
|
||||
import type {
|
||||
DomainEvent,
|
||||
Snapshot,
|
||||
} from "../../types/event-sourcing.types.js";
|
||||
import {
|
||||
createMemoryEventStore,
|
||||
MemoryEventStore,
|
||||
} from "./memory-event-store.js";
|
||||
|
||||
function makeEvent(
|
||||
aggregateId: string,
|
||||
type: string,
|
||||
version: number
|
||||
): DomainEvent {
|
||||
return {
|
||||
id: `e-${version}`,
|
||||
aggregateId,
|
||||
aggregateType: "TestAggregate",
|
||||
type,
|
||||
version,
|
||||
payload: {},
|
||||
metadata: {},
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("MemoryEventStore", () => {
|
||||
it("appends and loads events", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const e1 = makeEvent("a1", "Created", 1);
|
||||
await store.append([e1], 0);
|
||||
const events = await store.load("a1");
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.type).toBe("Created");
|
||||
});
|
||||
|
||||
it("loads from specific version", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
await store.append(
|
||||
[makeEvent("a1", "E1", 1), makeEvent("a1", "E2", 2)],
|
||||
0
|
||||
);
|
||||
const events = await store.load("a1", 1);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.type).toBe("E2");
|
||||
});
|
||||
|
||||
it("throws concurrency conflict on version mismatch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
await store.append([makeEvent("a1", "E1", 1)], 0);
|
||||
await expect(
|
||||
store.append([makeEvent("a1", "E2", 2)], 0)
|
||||
).rejects.toThrow(EventSourcingError);
|
||||
});
|
||||
|
||||
it("concurrency error has correct code", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
await store.append([makeEvent("a1", "E1", 1)], 0);
|
||||
const err = await store
|
||||
.append([makeEvent("a1", "E2", 2)], 0)
|
||||
.catch((e: unknown) => e);
|
||||
expect(err instanceof EventSourcingError).toBe(true);
|
||||
expect((err as EventSourcingError).code).toBe(
|
||||
ES_CODES.CONCURRENCY_CONFLICT
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array for unknown aggregate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const events = await store.load("unknown");
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("saves and loads snapshot", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const snap: Snapshot = {
|
||||
aggregateId: "a1",
|
||||
aggregateType: "TestAggregate",
|
||||
version: 5,
|
||||
state: { count: 10 },
|
||||
takenAt: new Date(),
|
||||
};
|
||||
await store.saveSnapshot(snap);
|
||||
const loaded = await store.loadSnapshot("a1");
|
||||
expect(loaded).toMatchObject({ version: 5 });
|
||||
});
|
||||
|
||||
it("loadSnapshot returns undefined when none", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const snap = await store.loadSnapshot("none");
|
||||
expect(snap).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getStream yields events", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
await store.append(
|
||||
[makeEvent("a1", "E1", 1), makeEvent("a1", "E2", 2)],
|
||||
0
|
||||
);
|
||||
const collected: DomainEvent[] = [];
|
||||
for await (const event of store.getStream("a1")) {
|
||||
collected.push(event);
|
||||
}
|
||||
expect(collected).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("getStream yields nothing for unknown aggregate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const collected: DomainEvent[] = [];
|
||||
for await (const event of store.getStream("unknown")) {
|
||||
collected.push(event);
|
||||
}
|
||||
expect(collected).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("respects maxStreamLength", { timeout: 5000 }, async () => {
|
||||
const store = createMemoryEventStore({ maxStreamLength: 2 });
|
||||
await store.append(
|
||||
[makeEvent("a1", "E1", 1), makeEvent("a1", "E2", 2)],
|
||||
0
|
||||
);
|
||||
await expect(
|
||||
store.append([makeEvent("a1", "E3", 3)], 2)
|
||||
).rejects.toThrow(EventSourcingError);
|
||||
});
|
||||
|
||||
it("maxStreamLength error has correct code", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = createMemoryEventStore({ maxStreamLength: 1 });
|
||||
await store.append([makeEvent("a1", "E1", 1)], 0);
|
||||
const err = await store
|
||||
.append([makeEvent("a1", "E2", 2)], 1)
|
||||
.catch((e: unknown) => e);
|
||||
expect((err as EventSourcingError).code).toBe(ES_CODES.APPEND_FAILED);
|
||||
});
|
||||
|
||||
it("publishes to bus after append", { timeout: 5000 }, async () => {
|
||||
const published: Array<{ topic: string; payload: unknown }> = [];
|
||||
const bus = {
|
||||
publish(topic: string, payload: unknown) {
|
||||
published.push({ topic, payload });
|
||||
},
|
||||
};
|
||||
const store = createMemoryEventStore({ bus });
|
||||
await store.append([makeEvent("a1", "E1", 1)], 0);
|
||||
expect(published).toHaveLength(1);
|
||||
expect(published[0]?.topic).toBe("domain-event:TestAggregate:E1");
|
||||
});
|
||||
|
||||
it("appends empty event list without error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
await expect(store.append([], 0)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("createMemoryEventStore returns EventStore interface", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const store = createMemoryEventStore();
|
||||
expect(typeof store.append).toBe("function");
|
||||
expect(typeof store.load).toBe("function");
|
||||
});
|
||||
|
||||
it("MemoryEventStore class is directly instantiable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const store = new MemoryEventStore();
|
||||
expect(store).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createMemoryEventStore,
|
||||
MemoryEventStore,
|
||||
} from "./memory-event-store.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,153 @@
|
||||
import { type Counter, type Histogram, trace } from "@opentelemetry/api";
|
||||
import {
|
||||
ES_CODES,
|
||||
EventSourcingError,
|
||||
} from "../../errors/event-sourcing-error.js";
|
||||
import type {
|
||||
DomainEvent,
|
||||
EventStore,
|
||||
MemoryEventStoreOptions,
|
||||
Snapshot,
|
||||
} from "../../types/event-sourcing.types.js";
|
||||
|
||||
const tracer = trace.getTracer("@suaveplan/event-sourcing");
|
||||
|
||||
function getCounter(name: string): Counter {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { metrics } =
|
||||
require("@opentelemetry/api") as typeof import("@opentelemetry/api");
|
||||
return metrics.getMeter("@suaveplan/event-sourcing").createCounter(name);
|
||||
}
|
||||
|
||||
function getHistogram(name: string): Histogram {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { metrics } =
|
||||
require("@opentelemetry/api") as typeof import("@opentelemetry/api");
|
||||
return metrics.getMeter("@suaveplan/event-sourcing").createHistogram(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory implementation of `EventStore` for testing and single-process use.
|
||||
*
|
||||
* Supports optimistic concurrency via `expectedVersion`, optional snapshot storage,
|
||||
* an async generator stream API, and optional bus publishing after commit.
|
||||
*/
|
||||
export class MemoryEventStore implements EventStore {
|
||||
protected readonly streams = new Map<string, DomainEvent[]>();
|
||||
protected readonly snapshots = new Map<string, Snapshot>();
|
||||
protected readonly opts: MemoryEventStoreOptions;
|
||||
|
||||
public constructor(opts: MemoryEventStoreOptions = {}) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
public async append(
|
||||
events: DomainEvent[],
|
||||
expectedVersion: number
|
||||
): Promise<void> {
|
||||
if (events.length === 0) return;
|
||||
const aggregateId = events[0]!.aggregateId;
|
||||
const span = tracer.startSpan("es.append");
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const stream = this.streams.get(aggregateId) ?? [];
|
||||
const currentVersion =
|
||||
stream.length > 0 ? stream[stream.length - 1]!.version : 0;
|
||||
|
||||
if (currentVersion !== expectedVersion) {
|
||||
getCounter("es.concurrency_conflicts_total").add(1);
|
||||
throw new EventSourcingError(
|
||||
ES_CODES.CONCURRENCY_CONFLICT,
|
||||
`Expected version ${expectedVersion} but stream is at version ${currentVersion}`,
|
||||
{
|
||||
context: {
|
||||
aggregateId,
|
||||
expectedVersion,
|
||||
currentVersion,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const maxLen = this.opts.maxStreamLength;
|
||||
if (
|
||||
maxLen !== undefined &&
|
||||
stream.length + events.length > maxLen
|
||||
) {
|
||||
throw new EventSourcingError(
|
||||
ES_CODES.APPEND_FAILED,
|
||||
`Stream length would exceed maxStreamLength (${maxLen})`,
|
||||
{ context: { aggregateId, maxStreamLength: maxLen } }
|
||||
);
|
||||
}
|
||||
|
||||
const newStream = [...stream, ...events];
|
||||
this.streams.set(aggregateId, newStream);
|
||||
|
||||
getCounter("es.events_appended_total").add(events.length);
|
||||
getHistogram("es.append_duration_ms").record(Date.now() - start);
|
||||
|
||||
span.setAttribute("es.aggregate_id", aggregateId);
|
||||
span.setAttribute("es.event_count", events.length);
|
||||
span.setAttribute("es.version", events[events.length - 1]!.version);
|
||||
|
||||
if (this.opts.bus) {
|
||||
for (const event of events) {
|
||||
const topic = `domain-event:${event.aggregateType}:${event.type}`;
|
||||
await this.opts.bus.publish(topic, event);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
public async load(
|
||||
aggregateId: string,
|
||||
fromVersion = 0
|
||||
): Promise<DomainEvent[]> {
|
||||
const span = tracer.startSpan("es.load");
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const stream = this.streams.get(aggregateId) ?? [];
|
||||
const filtered = stream.filter((e) => e.version > fromVersion);
|
||||
|
||||
getHistogram("es.load_duration_ms").record(Date.now() - start);
|
||||
span.setAttribute("es.aggregate_id", aggregateId);
|
||||
span.setAttribute("es.event_count", filtered.length);
|
||||
|
||||
return filtered;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
public async loadSnapshot(
|
||||
aggregateId: string
|
||||
): Promise<Snapshot | undefined> {
|
||||
return this.snapshots.get(aggregateId);
|
||||
}
|
||||
|
||||
public async saveSnapshot(snapshot: Snapshot): Promise<void> {
|
||||
this.snapshots.set(snapshot.aggregateId, snapshot);
|
||||
getCounter("es.snapshots_taken_total").add(1);
|
||||
}
|
||||
|
||||
public async *getStream(aggregateId: string): AsyncGenerator<DomainEvent> {
|
||||
const stream = this.streams.get(aggregateId) ?? [];
|
||||
for (const event of stream) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for creating a `MemoryEventStore`.
|
||||
*/
|
||||
export function createMemoryEventStore(
|
||||
options?: MemoryEventStoreOptions
|
||||
): EventStore {
|
||||
return new MemoryEventStore(options);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ES_CODES,
|
||||
type EventSourcingCode,
|
||||
EventSourcingError,
|
||||
} from "./event-sourcing-error.js";
|
||||
|
||||
describe("EventSourcingError", () => {
|
||||
it("has correct name", { timeout: 5000 }, () => {
|
||||
const err = new EventSourcingError(ES_CODES.APPEND_FAILED, "test");
|
||||
expect(err.name).toBe("EventSourcingError");
|
||||
});
|
||||
|
||||
it("carries the code", { timeout: 5000 }, () => {
|
||||
const err = new EventSourcingError(
|
||||
ES_CODES.CONCURRENCY_CONFLICT,
|
||||
"version mismatch"
|
||||
);
|
||||
expect(err.code).toBe(ES_CODES.CONCURRENCY_CONFLICT);
|
||||
});
|
||||
|
||||
it("carries the message", { timeout: 5000 }, () => {
|
||||
const err = new EventSourcingError(
|
||||
ES_CODES.AGGREGATE_NOT_FOUND,
|
||||
"not found"
|
||||
);
|
||||
expect(err.message).toBe("not found");
|
||||
});
|
||||
|
||||
it("chains cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root");
|
||||
const err = new EventSourcingError(ES_CODES.APPEND_FAILED, "wrap", {
|
||||
cause,
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("carries context", { timeout: 5000 }, () => {
|
||||
const err = new EventSourcingError(
|
||||
ES_CODES.CONCURRENCY_CONFLICT,
|
||||
"msg",
|
||||
{
|
||||
context: { aggregateId: "a1" },
|
||||
}
|
||||
);
|
||||
expect(
|
||||
(err as unknown as { context?: Record<string, unknown> }).context
|
||||
).toMatchObject({ aggregateId: "a1" });
|
||||
});
|
||||
|
||||
it("all codes defined", { timeout: 5000 }, () => {
|
||||
const codes: EventSourcingCode[] = [
|
||||
ES_CODES.CONCURRENCY_CONFLICT,
|
||||
ES_CODES.AGGREGATE_NOT_FOUND,
|
||||
ES_CODES.SNAPSHOT_INVALID,
|
||||
ES_CODES.UPCASTER_MISSING,
|
||||
ES_CODES.STREAM_CORRUPT,
|
||||
ES_CODES.APPEND_FAILED,
|
||||
];
|
||||
for (const code of codes) {
|
||||
expect(typeof code).toBe("string");
|
||||
expect(code).toMatch(/^ES\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("instanceof checks work", { timeout: 5000 }, () => {
|
||||
const err = new EventSourcingError(ES_CODES.STREAM_CORRUPT, "corrupt");
|
||||
expect(err instanceof EventSourcingError).toBe(true);
|
||||
expect(err instanceof Error).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/event-sourcing`.
|
||||
*/
|
||||
export const ES_CODES = defineCodes("ES", [
|
||||
"CONCURRENCY_CONFLICT",
|
||||
"AGGREGATE_NOT_FOUND",
|
||||
"SNAPSHOT_INVALID",
|
||||
"UPCASTER_MISSING",
|
||||
"STREAM_CORRUPT",
|
||||
"APPEND_FAILED",
|
||||
] as const);
|
||||
|
||||
export type EventSourcingCode = (typeof ES_CODES)[keyof typeof ES_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/event-sourcing` failures.
|
||||
*
|
||||
* Discriminate via `error instanceof EventSourcingError` or `error.code`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { EventSourcingError, ES_CODES } from "@suaveplan/event-sourcing";
|
||||
*
|
||||
* try {
|
||||
* await repository.save(aggregate);
|
||||
* } catch (err) {
|
||||
* if (err instanceof EventSourcingError && err.code === ES_CODES.CONCURRENCY_CONFLICT) {
|
||||
* // retry the load-modify-save cycle
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class EventSourcingError extends SuaveplanError<EventSourcingCode> {
|
||||
public constructor(
|
||||
code: EventSourcingCode,
|
||||
message: string,
|
||||
options?: { cause?: Error; context?: Record<string, unknown> }
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "EventSourcingError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { EventSourcingCode } from "./event-sourcing-error.js";
|
||||
export {
|
||||
ES_CODES,
|
||||
EventSourcingError,
|
||||
} from "./event-sourcing-error.js";
|
||||
52
packages/networking/events/event-sourcing/src/index.ts
Normal file
52
packages/networking/events/event-sourcing/src/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Errors
|
||||
|
||||
// Constants
|
||||
export {
|
||||
DEFAULT_SNAPSHOT_THRESHOLD,
|
||||
MAX_UPCAST_CHAIN_STEPS,
|
||||
} from "./constants/index.js";
|
||||
// Core — Aggregate
|
||||
export { Aggregate } from "./core/aggregate/aggregate.js";
|
||||
// Core — Repository
|
||||
export { createAggregateRepository } from "./core/aggregate-repository/aggregate-repository.js";
|
||||
// Core — Event Store
|
||||
export {
|
||||
createMemoryEventStore,
|
||||
MemoryEventStore,
|
||||
} from "./core/event-store/memory-event-store.js";
|
||||
export type { EventSourcingCode } from "./errors/event-sourcing-error.js";
|
||||
export {
|
||||
ES_CODES,
|
||||
EventSourcingError,
|
||||
} from "./errors/event-sourcing-error.js";
|
||||
export type { Projection } from "./projection/builder/builder.js";
|
||||
|
||||
// Projection
|
||||
export { createProjection } from "./projection/builder/builder.js";
|
||||
// Schemas
|
||||
export {
|
||||
DomainEventSchema,
|
||||
EventMetadataSchema,
|
||||
MemoryEventStoreOptionsSchema,
|
||||
SnapshotSchema,
|
||||
UpcasterOptionsSchema,
|
||||
} from "./schemas/event-sourcing.schemas.js";
|
||||
// Types
|
||||
export type {
|
||||
AggregateRepository,
|
||||
AggregateRepositoryOptions,
|
||||
DomainEvent,
|
||||
EventMetadata,
|
||||
EventStore,
|
||||
MemoryEventStoreOptions,
|
||||
ProjectionHandlers,
|
||||
ProjectionOptions,
|
||||
Snapshot,
|
||||
UpcasterOptions,
|
||||
} from "./types/event-sourcing.types.js";
|
||||
// Upcasting
|
||||
export {
|
||||
clearUpcasters,
|
||||
registerUpcaster,
|
||||
upcastEvents,
|
||||
} from "./upcasting/upcaster/upcaster.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DomainEvent } from "../../types/event-sourcing.types.js";
|
||||
import { createProjection } from "./builder.js";
|
||||
|
||||
function makeEvent(type: string, payload: unknown, version = 1): DomainEvent {
|
||||
return {
|
||||
id: `e-${version}`,
|
||||
aggregateId: "a1",
|
||||
aggregateType: "T",
|
||||
type,
|
||||
version,
|
||||
payload,
|
||||
metadata: {},
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createProjection", () => {
|
||||
it("projects empty events to initial state", { timeout: 5000 }, () => {
|
||||
const p = createProjection({ initialState: 0, handlers: {} });
|
||||
expect(p.project([])).toBe(0);
|
||||
});
|
||||
|
||||
it("applies handlers to events", { timeout: 5000 }, () => {
|
||||
const p = createProjection({
|
||||
initialState: { count: 0 },
|
||||
handlers: {
|
||||
Incremented: (s) => ({ count: s.count + 1 }),
|
||||
},
|
||||
});
|
||||
const result = p.project([
|
||||
makeEvent("Incremented", {}),
|
||||
makeEvent("Incremented", {}),
|
||||
]);
|
||||
expect(result.count).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores unknown event types", { timeout: 5000 }, () => {
|
||||
const p = createProjection({
|
||||
initialState: 0,
|
||||
handlers: { Known: (s) => s + 1 },
|
||||
});
|
||||
expect(p.project([makeEvent("Unknown", {})])).toBe(0);
|
||||
});
|
||||
|
||||
it("passes event to handler", { timeout: 5000 }, () => {
|
||||
const p = createProjection({
|
||||
initialState: 0,
|
||||
handlers: {
|
||||
Added: (s, e) => s + (e.payload as { amount: number }).amount,
|
||||
},
|
||||
});
|
||||
expect(p.project([makeEvent("Added", { amount: 5 })])).toBe(5);
|
||||
});
|
||||
|
||||
it("projectStream processes async iterable", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = createProjection({
|
||||
initialState: 0,
|
||||
handlers: { Tick: (s) => s + 1 },
|
||||
});
|
||||
async function* gen(): AsyncGenerator<DomainEvent> {
|
||||
yield makeEvent("Tick", {}, 1);
|
||||
yield makeEvent("Tick", {}, 2);
|
||||
yield makeEvent("Tick", {}, 3);
|
||||
}
|
||||
const result = await p.projectStream(gen());
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it("projectStream ignores unknown events", { timeout: 5000 }, async () => {
|
||||
const p = createProjection({ initialState: 0, handlers: {} });
|
||||
async function* gen(): AsyncGenerator<DomainEvent> {
|
||||
yield makeEvent("Unknown", {});
|
||||
}
|
||||
const result = await p.projectStream(gen());
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("starts fresh from initialState each call", { timeout: 5000 }, () => {
|
||||
const p = createProjection({
|
||||
initialState: { count: 0 },
|
||||
handlers: { Incremented: (s) => ({ count: s.count + 1 }) },
|
||||
});
|
||||
p.project([makeEvent("Incremented", {})]);
|
||||
expect(p.project([])).toEqual({ count: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
DomainEvent,
|
||||
Projection,
|
||||
ProjectionOptions,
|
||||
} from "../../types/event-sourcing.types.js";
|
||||
|
||||
/**
|
||||
* Builds a read-model projection from a declarative handler map.
|
||||
*
|
||||
* Each key in `handlers` is an event type string; the value is a reducer
|
||||
* function `(state, event) => newState`. Unknown event types are silently
|
||||
* ignored, enabling forward-compatible projections.
|
||||
*/
|
||||
class ProjectionImpl<TState> implements Projection<TState> {
|
||||
protected readonly initialState: TState;
|
||||
protected readonly handlers: Record<
|
||||
string,
|
||||
(state: TState, event: DomainEvent) => TState
|
||||
>;
|
||||
|
||||
public constructor(options: ProjectionOptions<TState>) {
|
||||
this.initialState = options.initialState;
|
||||
this.handlers = options.handlers;
|
||||
}
|
||||
|
||||
public project(events: DomainEvent[]): TState {
|
||||
let state = this.initialState;
|
||||
for (const event of events) {
|
||||
const handler = this.handlers[event.type];
|
||||
if (handler) {
|
||||
state = handler(state, event);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
public async projectStream(
|
||||
stream: AsyncIterable<DomainEvent>
|
||||
): Promise<TState> {
|
||||
let state = this.initialState;
|
||||
for await (const event of stream) {
|
||||
const handler = this.handlers[event.type];
|
||||
if (handler) {
|
||||
state = handler(state, event);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typed `Projection` from an initial state and handler map.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const projection = createProjection({
|
||||
* initialState: { total: 0 },
|
||||
* handlers: {
|
||||
* OrderPlaced: (state, event) => ({
|
||||
* total: state.total + (event.payload as { amount: number }).amount,
|
||||
* }),
|
||||
* },
|
||||
* });
|
||||
* const result = projection.project(events);
|
||||
* ```
|
||||
*/
|
||||
export function createProjection<TState>(
|
||||
options: ProjectionOptions<TState>
|
||||
): Projection<TState> {
|
||||
return new ProjectionImpl(options);
|
||||
}
|
||||
|
||||
export type { Projection } from "../../types/event-sourcing.types.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { Projection } from "./builder.js";
|
||||
export { createProjection } from "./builder.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DomainEventSchema,
|
||||
EventMetadataSchema,
|
||||
MemoryEventStoreOptionsSchema,
|
||||
SnapshotSchema,
|
||||
UpcasterOptionsSchema,
|
||||
} from "./event-sourcing.schemas.js";
|
||||
|
||||
describe("DomainEventSchema", () => {
|
||||
const valid = {
|
||||
id: "e1",
|
||||
aggregateId: "a1",
|
||||
aggregateType: "Order",
|
||||
type: "OrderPlaced",
|
||||
version: 1,
|
||||
payload: { amount: 100 },
|
||||
metadata: { actor: "user-1" },
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
it("parses valid event", { timeout: 5000 }, () => {
|
||||
expect(() => DomainEventSchema.parse(valid)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects zero version", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
DomainEventSchema.parse({ ...valid, version: 0 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing id", { timeout: 5000 }, () => {
|
||||
const { id: _id, ...rest } = valid;
|
||||
expect(() => DomainEventSchema.parse(rest)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects string occurredAt", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
DomainEventSchema.parse({ ...valid, occurredAt: "2024-01-01" })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SnapshotSchema", () => {
|
||||
const valid = {
|
||||
aggregateId: "a1",
|
||||
aggregateType: "Order",
|
||||
version: 5,
|
||||
state: { balance: 100 },
|
||||
takenAt: new Date(),
|
||||
};
|
||||
|
||||
it("parses valid snapshot", { timeout: 5000 }, () => {
|
||||
expect(() => SnapshotSchema.parse(valid)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts version 0", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
SnapshotSchema.parse({ ...valid, version: 0 })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects negative version", { timeout: 5000 }, () => {
|
||||
expect(() => SnapshotSchema.parse({ ...valid, version: -1 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventMetadataSchema", () => {
|
||||
it("parses empty metadata", { timeout: 5000 }, () => {
|
||||
expect(() => EventMetadataSchema.parse({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes through unknown keys", { timeout: 5000 }, () => {
|
||||
const result = EventMetadataSchema.parse({ custom: "value" });
|
||||
expect(result).toMatchObject({ custom: "value" });
|
||||
});
|
||||
|
||||
it("accepts all optional fields", { timeout: 5000 }, () => {
|
||||
const result = EventMetadataSchema.parse({
|
||||
causationId: "c1",
|
||||
correlationId: "r1",
|
||||
actor: "user-1",
|
||||
});
|
||||
expect(result.actor).toBe("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryEventStoreOptionsSchema", () => {
|
||||
it("accepts undefined", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
MemoryEventStoreOptionsSchema.parse(undefined)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts maxStreamLength", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
MemoryEventStoreOptionsSchema.parse({ maxStreamLength: 1000 })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects zero maxStreamLength", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
MemoryEventStoreOptionsSchema.parse({ maxStreamLength: 0 })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpcasterOptionsSchema", () => {
|
||||
it("parses valid upcaster", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
UpcasterOptionsSchema.parse({
|
||||
eventType: "OrderPlaced",
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
upcast: (p: unknown) => p,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects negative fromVersion", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
UpcasterOptionsSchema.parse({
|
||||
eventType: "E",
|
||||
fromVersion: -1,
|
||||
toVersion: 1,
|
||||
upcast: (p: unknown) => p,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for `EventMetadata`.
|
||||
*/
|
||||
export const EventMetadataSchema = z
|
||||
.object({
|
||||
causationId: z.string().optional(),
|
||||
correlationId: z.string().optional(),
|
||||
actor: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* Zod schema for `DomainEvent`.
|
||||
*/
|
||||
export const DomainEventSchema = z.object({
|
||||
id: z.string(),
|
||||
aggregateId: z.string(),
|
||||
aggregateType: z.string(),
|
||||
type: z.string(),
|
||||
version: z.number().int().positive(),
|
||||
payload: z.unknown(),
|
||||
metadata: EventMetadataSchema,
|
||||
occurredAt: z.date(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for `Snapshot`.
|
||||
*/
|
||||
export const SnapshotSchema = z.object({
|
||||
aggregateId: z.string(),
|
||||
aggregateType: z.string(),
|
||||
version: z.number().int().nonnegative(),
|
||||
state: z.unknown(),
|
||||
takenAt: z.date(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for `MemoryEventStoreOptions`.
|
||||
*/
|
||||
export const MemoryEventStoreOptionsSchema = z
|
||||
.object({
|
||||
maxStreamLength: z.number().int().positive().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Zod schema for `UpcasterOptions`.
|
||||
*/
|
||||
export const UpcasterOptionsSchema = z.object({
|
||||
eventType: z.string(),
|
||||
fromVersion: z.number().int().nonnegative(),
|
||||
toVersion: z.number().int().positive(),
|
||||
upcast: z.function(),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
DomainEventSchema,
|
||||
EventMetadataSchema,
|
||||
MemoryEventStoreOptionsSchema,
|
||||
SnapshotSchema,
|
||||
UpcasterOptionsSchema,
|
||||
} from "./event-sourcing.schemas.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Metadata carried on every domain event.
|
||||
*/
|
||||
export interface EventMetadata {
|
||||
causationId?: string;
|
||||
correlationId?: string;
|
||||
actor?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A domain event emitted by an aggregate.
|
||||
*
|
||||
* @typeParam TPayload - The strongly-typed payload for this event.
|
||||
*/
|
||||
export interface DomainEvent<TPayload = unknown> {
|
||||
/** Unique event ID (UUID). */
|
||||
id: string;
|
||||
/** The aggregate this event belongs to. */
|
||||
aggregateId: string;
|
||||
/** Type tag of the aggregate (e.g. `"Order"`). */
|
||||
aggregateType: string;
|
||||
/** Discriminator string for the event (e.g. `"OrderPlaced"`). */
|
||||
type: string;
|
||||
/** Sequence position within the aggregate stream (1-based). */
|
||||
version: number;
|
||||
/** Event payload. */
|
||||
payload: TPayload;
|
||||
/** Cross-cutting metadata. */
|
||||
metadata: EventMetadata;
|
||||
/** Wall-clock time the event occurred. */
|
||||
occurredAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* A point-in-time snapshot of aggregate state.
|
||||
*/
|
||||
export interface Snapshot {
|
||||
aggregateId: string;
|
||||
aggregateType: string;
|
||||
/** The stream version at which this snapshot was taken. */
|
||||
version: number;
|
||||
/** Opaque serialized state. */
|
||||
state: unknown;
|
||||
takenAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the in-memory event store.
|
||||
*/
|
||||
export interface MemoryEventStoreOptions {
|
||||
/** Cap each stream to this many events; excess raises an error. */
|
||||
maxStreamLength?: number;
|
||||
clock?: Clock;
|
||||
idGenerator?: IdGenerator;
|
||||
/** Publish committed events as `domain-event:<type>:<event>` topics. */
|
||||
bus?: { publish(topic: string, payload: unknown): Promise<void> | void };
|
||||
logger?: {
|
||||
debug(msg: string, ctx?: Record<string, unknown>): void;
|
||||
warn(msg: string, ctx?: Record<string, unknown>): void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the aggregate repository.
|
||||
*/
|
||||
export interface AggregateRepositoryOptions<T> {
|
||||
store: EventStore;
|
||||
/** Factory that creates a blank aggregate given an ID. */
|
||||
factory: (id: string) => T;
|
||||
/** Automatically snapshot every N events (default: disabled). */
|
||||
snapshotThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The core event store interface.
|
||||
*/
|
||||
export 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection handler map.
|
||||
*/
|
||||
export type ProjectionHandlers<TState> = Record<
|
||||
string,
|
||||
(state: TState, event: DomainEvent) => TState
|
||||
>;
|
||||
|
||||
/**
|
||||
* Options for building a projection.
|
||||
*/
|
||||
export interface ProjectionOptions<TState> {
|
||||
initialState: TState;
|
||||
handlers: ProjectionHandlers<TState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-model projection that folds domain events into state.
|
||||
*/
|
||||
export interface Projection<TState> {
|
||||
project(events: DomainEvent[]): TState;
|
||||
projectStream(stream: AsyncIterable<DomainEvent>): Promise<TState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for registering an upcaster.
|
||||
*/
|
||||
export interface UpcasterOptions {
|
||||
eventType: string;
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
upcast: (payload: unknown) => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository interface returned from `createAggregateRepository`.
|
||||
*/
|
||||
export interface AggregateRepository<T> {
|
||||
load(aggregateId: string): Promise<T>;
|
||||
save(aggregate: T): Promise<void>;
|
||||
exists(aggregateId: string): Promise<boolean>;
|
||||
}
|
||||
12
packages/networking/events/event-sourcing/src/types/index.ts
Normal file
12
packages/networking/events/event-sourcing/src/types/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
AggregateRepository,
|
||||
AggregateRepositoryOptions,
|
||||
DomainEvent,
|
||||
EventMetadata,
|
||||
EventStore,
|
||||
MemoryEventStoreOptions,
|
||||
ProjectionHandlers,
|
||||
ProjectionOptions,
|
||||
Snapshot,
|
||||
UpcasterOptions,
|
||||
} from "./event-sourcing.types.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { clearUpcasters, registerUpcaster, upcastEvents } from "./upcaster.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DomainEvent } from "../../types/event-sourcing.types.js";
|
||||
import { clearUpcasters, registerUpcaster, upcastEvents } from "./upcaster.js";
|
||||
|
||||
function makeEvent(type: string, payload: unknown): DomainEvent {
|
||||
return {
|
||||
id: "e1",
|
||||
aggregateId: "a1",
|
||||
aggregateType: "T",
|
||||
type,
|
||||
version: 1,
|
||||
payload,
|
||||
metadata: {},
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("upcastEvents", () => {
|
||||
afterEach(() => {
|
||||
clearUpcasters();
|
||||
});
|
||||
|
||||
it("returns events unchanged when no upcasters", { timeout: 5000 }, () => {
|
||||
const events = [makeEvent("OrderPlaced", { amount: 100 })];
|
||||
const result = upcastEvents(events);
|
||||
expect(result[0]?.payload).toEqual({ amount: 100 });
|
||||
});
|
||||
|
||||
it("applies single upcaster", { timeout: 5000 }, () => {
|
||||
registerUpcaster({
|
||||
eventType: "OrderPlaced",
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
upcast: (p) => ({ ...(p as object), currency: "USD" }),
|
||||
});
|
||||
const result = upcastEvents([
|
||||
makeEvent("OrderPlaced", { amount: 100 }),
|
||||
]);
|
||||
expect((result[0]?.payload as { currency: string }).currency).toBe(
|
||||
"USD"
|
||||
);
|
||||
});
|
||||
|
||||
it("applies chained upcasters", { timeout: 5000 }, () => {
|
||||
registerUpcaster({
|
||||
eventType: "E",
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
upcast: (p) => ({ ...(p as object), v2: true }),
|
||||
});
|
||||
registerUpcaster({
|
||||
eventType: "E",
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
upcast: (p) => ({ ...(p as object), v3: true }),
|
||||
});
|
||||
const result = upcastEvents([makeEvent("E", {})]);
|
||||
const payload = result[0]?.payload as { v2: boolean; v3: boolean };
|
||||
expect(payload.v2).toBe(true);
|
||||
expect(payload.v3).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mutate original events", { timeout: 5000 }, () => {
|
||||
registerUpcaster({
|
||||
eventType: "E",
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
upcast: (p) => ({ ...(p as object), added: true }),
|
||||
});
|
||||
const original = makeEvent("E", { original: true });
|
||||
upcastEvents([original]);
|
||||
expect((original.payload as { added?: boolean }).added).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores events without registered upcasters", { timeout: 5000 }, () => {
|
||||
const events = [makeEvent("UnknownEvent", { x: 1 })];
|
||||
expect(() => upcastEvents(events)).not.toThrow();
|
||||
});
|
||||
|
||||
it("clearUpcasters resets registry", { timeout: 5000 }, () => {
|
||||
registerUpcaster({
|
||||
eventType: "E",
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
upcast: (p) => ({ ...(p as object), added: true }),
|
||||
});
|
||||
clearUpcasters();
|
||||
const result = upcastEvents([makeEvent("E", {})]);
|
||||
expect(
|
||||
(result[0]?.payload as { added?: boolean }).added
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles empty event array", { timeout: 5000 }, () => {
|
||||
expect(upcastEvents([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("requireComplete does not throw when chain is complete", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registerUpcaster({
|
||||
eventType: "E",
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
upcast: (p) => p,
|
||||
});
|
||||
// fromVersion 2 has no registered upcaster — chain terminates
|
||||
expect(() =>
|
||||
upcastEvents([makeEvent("E", {})], { requireComplete: true })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("requireComplete throws when iteration cap exceeded with next upcaster present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Register 101 upcasters (G:1→2, G:2→3, ..., G:101→102).
|
||||
// The loop cap is 100 iterations so it stops at payloadVersion=101.
|
||||
// G:101 is still registered, so requireComplete throws.
|
||||
for (let v = 1; v <= 101; v++) {
|
||||
registerUpcaster({
|
||||
eventType: "G",
|
||||
fromVersion: v,
|
||||
toVersion: v + 1,
|
||||
upcast: (p) => p,
|
||||
});
|
||||
}
|
||||
expect(() =>
|
||||
upcastEvents([makeEvent("G", {})], { requireComplete: true })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
ES_CODES,
|
||||
EventSourcingError,
|
||||
} from "../../errors/event-sourcing-error.js";
|
||||
import type {
|
||||
DomainEvent,
|
||||
UpcasterOptions,
|
||||
} from "../../types/event-sourcing.types.js";
|
||||
|
||||
/**
|
||||
* Registry of upcasters keyed by `eventType:fromVersion`.
|
||||
*/
|
||||
const registry = new Map<string, UpcasterOptions>();
|
||||
|
||||
/**
|
||||
* Registers an upcaster that migrates event payloads from `fromVersion` to
|
||||
* `toVersion` for the given `eventType`.
|
||||
*
|
||||
* Upcasters are applied in sequence during `upcastEvents`.
|
||||
*/
|
||||
export function registerUpcaster(options: UpcasterOptions): void {
|
||||
const key = `${options.eventType}:${options.fromVersion}`;
|
||||
registry.set(key, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all registered upcasters (useful in tests).
|
||||
*/
|
||||
export function clearUpcasters(): void {
|
||||
registry.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies registered upcasters to a sequence of events.
|
||||
*
|
||||
* For each event, if an upcaster is registered for `event.type` at the
|
||||
* current payload version, it is applied and the process repeats until no
|
||||
* more upcasters match.
|
||||
*
|
||||
* @throws `EventSourcingError(ES.UPCASTER_MISSING)` when `requireComplete` is
|
||||
* `true` and an event's payload version has no registered upcaster path to
|
||||
* the latest version.
|
||||
*/
|
||||
export function upcastEvents(
|
||||
events: DomainEvent[],
|
||||
options?: { requireComplete?: boolean }
|
||||
): DomainEvent[] {
|
||||
return events.map((event) =>
|
||||
upcastEvent(event, options?.requireComplete ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
function upcastEvent(
|
||||
event: DomainEvent,
|
||||
requireComplete: boolean
|
||||
): DomainEvent {
|
||||
let current = event;
|
||||
let payloadVersion = 1; // default payload version if not tracked
|
||||
|
||||
// Walk the upcaster chain
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const key = `${current.type}:${payloadVersion}`;
|
||||
const upcaster = registry.get(key);
|
||||
if (!upcaster) break;
|
||||
const upcasted = upcaster.upcast(current.payload);
|
||||
current = { ...current, payload: upcasted };
|
||||
payloadVersion = upcaster.toVersion;
|
||||
}
|
||||
|
||||
if (requireComplete) {
|
||||
// Check that no pending upcaster exists at the current version
|
||||
const nextKey = `${current.type}:${payloadVersion}`;
|
||||
if (registry.has(nextKey)) {
|
||||
throw new EventSourcingError(
|
||||
ES_CODES.UPCASTER_MISSING,
|
||||
`Upcaster chain incomplete for event type "${current.type}" at version ${payloadVersion}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("@suaveplan/event-sourcing dep-graph", () => {
|
||||
it("imports without error", { timeout: 30000 }, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(typeof mod.Aggregate).toBe("function");
|
||||
expect(typeof mod.MemoryEventStore).toBe("function");
|
||||
expect(typeof mod.createMemoryEventStore).toBe("function");
|
||||
expect(typeof mod.createAggregateRepository).toBe("function");
|
||||
expect(typeof mod.createProjection).toBe("function");
|
||||
expect(typeof mod.registerUpcaster).toBe("function");
|
||||
expect(typeof mod.upcastEvents).toBe("function");
|
||||
expect(typeof mod.clearUpcasters).toBe("function");
|
||||
expect(typeof mod.EventSourcingError).toBe("function");
|
||||
expect(mod.ES_CODES).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Aggregate } from "../src/core/aggregate/aggregate.js";
|
||||
import { createAggregateRepository } from "../src/core/aggregate-repository/aggregate-repository.js";
|
||||
import { createMemoryEventStore } from "../src/core/event-store/memory-event-store.js";
|
||||
import { createProjection } from "../src/projection/builder/builder.js";
|
||||
import type { DomainEvent } from "../src/types/event-sourcing.types.js";
|
||||
|
||||
interface AccountState {
|
||||
balance: number;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
class BankAccount extends Aggregate<AccountState> {
|
||||
public constructor(id: string) {
|
||||
super(id, { balance: 0, open: true });
|
||||
}
|
||||
|
||||
public deposit(amount: number): void {
|
||||
this.raise("Deposited", { amount });
|
||||
}
|
||||
|
||||
public withdraw(amount: number): void {
|
||||
if (amount > this.state.balance) throw new Error("Insufficient funds");
|
||||
this.raise("Withdrawn", { amount });
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.raise("AccountClosed", {});
|
||||
}
|
||||
|
||||
protected applyEvent(event: DomainEvent): void {
|
||||
const p = event.payload as { amount: number };
|
||||
switch (event.type) {
|
||||
case "Deposited":
|
||||
this.state = {
|
||||
...this.state,
|
||||
balance: this.state.balance + p.amount,
|
||||
};
|
||||
break;
|
||||
case "Withdrawn":
|
||||
this.state = {
|
||||
...this.state,
|
||||
balance: this.state.balance - p.amount,
|
||||
};
|
||||
break;
|
||||
case "AccountClosed":
|
||||
this.state = { ...this.state, open: false };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public get balance(): number {
|
||||
return this.state.balance;
|
||||
}
|
||||
public get isOpen(): boolean {
|
||||
return this.state.open;
|
||||
}
|
||||
}
|
||||
|
||||
describe("event-sourcing integration", () => {
|
||||
it("full lifecycle: create, save, reload", { timeout: 30000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new BankAccount(id),
|
||||
});
|
||||
|
||||
const account = new BankAccount("acc-1");
|
||||
account.deposit(1000);
|
||||
account.withdraw(250);
|
||||
await repo.save(account);
|
||||
|
||||
const loaded = await repo.load("acc-1");
|
||||
expect(loaded.balance).toBe(750);
|
||||
expect(loaded.getVersion()).toBe(2);
|
||||
});
|
||||
|
||||
it("project events to read model", { timeout: 30000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new BankAccount(id),
|
||||
});
|
||||
|
||||
const account = new BankAccount("acc-2");
|
||||
account.deposit(500);
|
||||
account.deposit(200);
|
||||
account.withdraw(100);
|
||||
await repo.save(account);
|
||||
|
||||
const totalDeposits = createProjection({
|
||||
initialState: 0,
|
||||
handlers: {
|
||||
Deposited: (s, e) =>
|
||||
s + (e.payload as { amount: number }).amount,
|
||||
},
|
||||
});
|
||||
|
||||
const events = await store.load("acc-2");
|
||||
expect(totalDeposits.project(events)).toBe(700);
|
||||
});
|
||||
|
||||
it("snapshot reduces replay events", { timeout: 30000 }, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new BankAccount(id),
|
||||
snapshotThreshold: 3,
|
||||
});
|
||||
|
||||
const account = new BankAccount("acc-3");
|
||||
account.deposit(100);
|
||||
account.deposit(200);
|
||||
account.deposit(300);
|
||||
await repo.save(account); // should snapshot at version 3
|
||||
|
||||
const snap = await store.loadSnapshot("acc-3");
|
||||
expect(snap).toBeDefined();
|
||||
expect(snap?.version).toBe(3);
|
||||
|
||||
const loaded = await repo.load("acc-3");
|
||||
expect(loaded.balance).toBe(600);
|
||||
});
|
||||
|
||||
it("concurrent save raises concurrency conflict", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const store = createMemoryEventStore();
|
||||
const repo = createAggregateRepository({
|
||||
store,
|
||||
factory: (id) => new BankAccount(id),
|
||||
});
|
||||
|
||||
// First save
|
||||
const a1 = new BankAccount("acc-4");
|
||||
a1.deposit(100);
|
||||
await repo.save(a1);
|
||||
|
||||
// Load same aggregate twice
|
||||
const b1 = await repo.load("acc-4");
|
||||
const b2 = await repo.load("acc-4");
|
||||
|
||||
b1.deposit(50);
|
||||
b2.withdraw(50);
|
||||
|
||||
await repo.save(b1);
|
||||
// b2 has stale version — should conflict
|
||||
await expect(repo.save(b2)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
12
packages/networking/events/event-sourcing/tsconfig.lib.json
Normal file
12
packages/networking/events/event-sourcing/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
39
packages/networking/events/event-sourcing/vite.config.ts
Normal file
39
packages/networking/events/event-sourcing/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/event-sourcing",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/diff",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
26
packages/networking/events/event-sourcing/vitest.config.ts
Normal file
26
packages/networking/events/event-sourcing/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user