import type EventBus from "./EventBus/EventBus"; import AggregateRootNotFoundException from "./Exception/AggregateRootNotFoundException"; import type IEventStoreDBAL from "./IEventStoreDBAL"; import SnapshotStore from "./Snapshot/SnapshotStore"; import type ISnapshotStoreDBAL from "./Snapshot/SnapshotStoreDBAL"; import type { UpcasterChain } from "./Upcasting/UpcasterChain"; import EventSourcedAggregateRoot from "../Domain/EventSourcedAggregateRoot"; import { Identity } from "../Domain/AggregateRoot"; import DomainEvent from "../Domain/Event/DomainEvent"; import DomainEventStream from "../Domain/Event/DomainEventStream"; import DomainMessage from "../Domain/Event/DomainMessage"; export type AggregateFactory = new (aggregateRootID: Identity) => T; const MIN_SNAPSHOT_MARGIN: number = 10; export default class EventStore { private readonly dbal: IEventStoreDBAL; private readonly eventBus: EventBus; private readonly snapshotStore?: SnapshotStore; private readonly modelConstructor: AggregateFactory; private readonly snapshotMargin: number; private readonly upcasterChain?: UpcasterChain; constructor( modelConstructor: AggregateFactory, dbal: IEventStoreDBAL, eventBus: EventBus, snapshotStoreDbal?: ISnapshotStoreDBAL, snapshotMargin?: number, upcasterChain?: UpcasterChain, ) { this.modelConstructor = modelConstructor; this.dbal = dbal; this.eventBus = eventBus; this.snapshotMargin = snapshotMargin || MIN_SNAPSHOT_MARGIN; this.upcasterChain = upcasterChain; if (snapshotStoreDbal) { this.snapshotStore = new SnapshotStore(snapshotStoreDbal); } } public async load(aggregateRootId: Identity): Promise { let aggregateRoot: T | null; aggregateRoot = await this.fromSnapshot(aggregateRootId); // If loading from snapshot, we need to load events AFTER the snapshot version // version() returns the last applied playhead, so we need version() + 1 let stream: DomainEventStream = await this.dbal.load( aggregateRootId.toString(), aggregateRoot ? aggregateRoot.version() + 1 : 0, ); if (stream.isEmpty() && !aggregateRoot) { throw new AggregateRootNotFoundException(); } // Apply upcasting to migrate events to their latest versions stream = this.applyUpcasting(stream); aggregateRoot = aggregateRoot || this.aggregateFactory(aggregateRootId); return aggregateRoot.fromHistory(stream); } public async save(entity: T): Promise { const stream: DomainEventStream = entity.getUncommittedEvents(); const expectedVersion = this.calculateExpectedVersion(entity, stream); await this.append(entity.getAggregateRootId().toString(), stream, expectedVersion); await this.takeSnapshot(entity); for (const message of stream.events) { await this.eventBus.publish(message); } } public async append(aggregateId: string, stream: DomainEventStream, expectedVersion?: number): Promise { await this.dbal.append(aggregateId, stream, expectedVersion); } private calculateExpectedVersion(entity: T, stream: DomainEventStream): number { // Expected version is the version before the uncommitted events were applied // Current version - number of uncommitted events return entity.version() - stream.events.length; } public async replayFrom(uuid: string, from: number, to?: number): Promise { const replayStream: DomainEventStream = await this.dbal.loadFromTo(uuid, from, to); for (const message of replayStream.events) { await this.eventBus.publish(message); } } private async takeSnapshot(entity: T): Promise { if (this.snapshotStore && this.isSnapshotNeeded(entity.version())) { await this.snapshotStore.snapshot(entity); } } private isSnapshotNeeded(version: number): boolean { return version !== 0 && version / this.snapshotMargin >= 1 && version % this.snapshotMargin === 0; } private async fromSnapshot(aggregateRootId: Identity): Promise { if (!this.snapshotStore) { return null; } const snapshot = await this.snapshotStore.retrieve(aggregateRootId.toString()); if (!snapshot) { return null; } const aggregateRoot = this.aggregateFactory(aggregateRootId); aggregateRoot.fromSnapshot(snapshot); return aggregateRoot; } private aggregateFactory(aggregateRootId: Identity): T { return new this.modelConstructor(aggregateRootId); } /** * Applies upcasting to all events in the stream. * Events are migrated through the upcaster chain to their latest versions. * * @param stream - The original event stream * @returns A new stream with upcasted events */ private applyUpcasting(stream: DomainEventStream): DomainEventStream { if (!this.upcasterChain) { return stream; } const upcastedEvents = stream.events.map((message) => { const event = message.event; // Only upcast if the event has version property (for upcasting support) if (typeof event === 'object' && 'version' in event) { const upcastedEvent = this.upcasterChain!.upcast(event as DomainEvent); // If event was upcasted, create a new message with the upcasted event if (upcastedEvent !== event) { return DomainMessage.create( message.uuid, message.playhead, upcastedEvent, message.metadata, ); } } return message; }); return new DomainEventStream(upcastedEvents, stream.name); } }