feat: update aggregate implementation

This commit is contained in:
2025-08-11 13:34:28 +02:00
parent 9dddc4e79f
commit cc8c558db6
37 changed files with 361 additions and 511 deletions

View File

@@ -1,74 +0,0 @@
import { AggregateRootClass } from "./aggregate.ts";
import { EventFactory } from "./event-factory.ts";
import { AnyEventStore } from "./event-store.ts";
/**
* Indexes a list of event factories for use with aggregates and event stores
* when generating or accessing event functionality.
*
* @example
*
* ```ts
* import { AggregateRoot, AggregateFactory } from "@valkyr/event-store";
* import z from "zod";
*
* class User extends AggregateRoot {}
*
* const factory = new AggregateFactory([User]);
*
* export type Aggregates = typeof factory.$aggregates;
* ```
*/
export class AggregateFactory<
const TEventFactory extends EventFactory = EventFactory,
const TAggregates extends AggregateRootClass<TEventFactory>[] = AggregateRootClass<TEventFactory>[],
> {
/**
* Optimized aggregate lookup index.
*/
readonly #index = new Map<TAggregates[number]["name"], TAggregates[number]>();
aggregates: TAggregates;
/**
* Inferred type of the aggregates registered with the factory.
*/
declare readonly $aggregates: TAggregates;
/**
* Instantiate a new AggregateFactory with given list of supported aggregates.
*
* @param aggregates - Aggregates to register with the factory.
*/
constructor(aggregates: TAggregates) {
this.aggregates = aggregates;
for (const aggregate of aggregates) {
this.#index.set(aggregate.name, aggregate);
}
}
/**
* Attaches the given store to all the aggregates registered with this instance.
*
* If the factory is passed into multiple event stores, the aggregates will be
* overriden by the last execution. Its recommended to create individual instances
* for each list of aggregates.
*
* @param store - Event store to attach to the aggregates.
*/
withStore(store: AnyEventStore): this {
for (const aggregate of this.aggregates) {
aggregate.$store = store;
}
return this;
}
/**
* Get a registered aggregate from the factory.
*
* @param name - Aggregate to retrieve.
*/
get<TName extends TAggregates[number]["name"]>(name: TName): Extract<TAggregates[number], { name: TName }> {
return this.#index.get(name) as Extract<TAggregates[number], { name: TName }>;
}
}

View File

@@ -1,6 +1,8 @@
import type { AnyEventStore, EventsInsertSettings } from "../libraries/event-store.ts";
import type { Unknown } from "../types/common.ts";
import { AggregateSnapshotViolation, AggregateStreamViolation } from "./errors.ts";
import { EventFactory } from "./event-factory.ts";
import { makeAggregateReducer } from "./reducer.ts";
/**
* Represents an aggregate root in an event-sourced system.
@@ -18,39 +20,43 @@ export abstract class AggregateRoot<TEventFactory extends EventFactory> {
*/
static readonly name: string;
readonly #store: AnyEventStore;
/**
* Event store to transact against.
* Primary unique identifier for the stream the aggregate belongs to.
*/
protected static _store?: AnyEventStore;
#stream?: string;
/**
* List of pending records to push to the parent event store.
*/
#pending: TEventFactory["$events"][number]["$record"][] = [];
/**
* Instantiate a new AggregateRoot with a given event store instance.
*
* @param store - Store this aggregate instance acts against.
*/
constructor(store: AnyEventStore) {
this.#store = store;
}
// -------------------------------------------------------------------------
// Accessors
// -------------------------------------------------------------------------
static get $store(): AnyEventStore {
if (this._store === undefined) {
throw new Error(`Aggregate Root > Failed to retrieve store for '${this.name}', no store has been attached.`);
set id(value: string) {
if (this.#stream !== undefined) {
throw new AggregateStreamViolation(this.constructor.name);
}
return this._store;
this.#stream = value;
}
static set $store(store: AnyEventStore) {
// if (this._store !== undefined) {
// throw new Error(`Aggregate '${this.constructor.name}' already has store assigned`);
// }
this._store = store;
}
/**
* Get store instance attached to the static aggregate.
*/
get $store(): AnyEventStore {
return (this.constructor as any).$store;
get id() {
if (this.#stream === undefined) {
this.#stream = crypto.randomUUID();
}
return this.#stream;
}
/**
@@ -74,9 +80,10 @@ export abstract class AggregateRoot<TEventFactory extends EventFactory> {
*/
static from<TEventFactory extends EventFactory, TAggregateRoot extends typeof AggregateRoot<TEventFactory>>(
this: TAggregateRoot,
store: AnyEventStore,
snapshot?: Unknown,
): InstanceType<TAggregateRoot> {
const instance = new (this as any)();
const instance = new (this as any)(store);
if (snapshot !== undefined) {
Object.assign(instance, snapshot);
}
@@ -109,7 +116,7 @@ export abstract class AggregateRoot<TEventFactory extends EventFactory> {
push<TType extends TEventFactory["$events"][number]["state"]["type"]>(
record: { type: TType } & Extract<TEventFactory["$events"][number], { state: { type: TType } }>["$payload"],
): this {
const pending = this.$store.event(record);
const pending = this.#store.event(record);
this.#pending.push(pending);
this.with(pending);
return this;
@@ -136,13 +143,25 @@ export abstract class AggregateRoot<TEventFactory extends EventFactory> {
if (this.isDirty === false) {
return this;
}
await this.$store.pushManyEvents(this.#pending, settings);
await this.#store.pushManyEvents(this.#pending, settings);
if (flush === true) {
this.flush();
}
return this;
}
async snapshot() {
const stream = this.#stream;
if (stream === undefined) {
throw new AggregateSnapshotViolation((this.constructor as typeof AggregateRoot<TEventFactory>).name);
}
await this.#store.createSnapshot({
name: this.constructor.name,
stream,
reducer: makeAggregateReducer(this.#store, this.constructor as typeof AggregateRoot<TEventFactory>),
});
}
/**
* Removes all events from the aggregate #pending list.
*/

View File

@@ -1,3 +1,42 @@
/*
|--------------------------------------------------------------------------------
| Aggregate Errors
|--------------------------------------------------------------------------------
*/
/**
* Error thrown when stream assignment on the aggregate has already been set.
*
* @property name - Name of the aggregate throwing the error.
*/
export class AggregateStreamViolation extends Error {
readonly type = "AggregateStreamAlreadySet";
constructor(name: string) {
super(`EventStore Error: Aggregate '${name}' already has a stream assigned, overriding not supported.`);
}
}
/**
* Error thrown when attempting to snapshot an aggregate without a resolved
* stream.
*
* @property name - Name of the aggregate throwing the error.
*/
export class AggregateSnapshotViolation extends Error {
readonly type = "AggregateSnapshotViolation";
constructor(name: string) {
super(`EventStore Error: Aggregate '${name}' has no stream assigned, snapshot generation cannot be executed.`);
}
}
/*
|--------------------------------------------------------------------------------
| Event Errors
|--------------------------------------------------------------------------------
*/
/**
* Error thrown when an expected event is missing from the event store.
*
@@ -14,12 +53,6 @@ export class EventMissingError extends Error {
}
}
/*
|--------------------------------------------------------------------------------
| Event Errors
|--------------------------------------------------------------------------------
*/
/**
* Error thrown when an event fails validation checks.
*

View File

@@ -35,8 +35,7 @@
import { EventStoreAdapter } from "../types/adapter.ts";
import type { Unknown } from "../types/common.ts";
import type { EventReadOptions, ReduceQuery } from "../types/query.ts";
import type { AggregateRoot } from "./aggregate.ts";
import { AggregateFactory } from "./aggregate-factory.ts";
import { AggregateRootClass } from "./aggregate.ts";
import { EventInsertionError, EventMissingError, EventValidationError } from "./errors.ts";
import { EventStatus } from "./event.ts";
import { EventFactory } from "./event-factory.ts";
@@ -53,24 +52,21 @@ import { makeAggregateReducer, makeReducer } from "./reducer.ts";
* Provides a common interface to interact with a event storage solution. Its built
* on an adapter pattern to allow for multiple different storage drivers.
*/
export class EventStore<
TEventFactory extends EventFactory,
TAggregateFactory extends AggregateFactory<TEventFactory>,
TEventStoreAdapter extends EventStoreAdapter<any>,
> {
export class EventStore<TEventFactory extends EventFactory, TEventStoreAdapter extends EventStoreAdapter<any>> {
readonly uuid: string;
readonly #adapter: TEventStoreAdapter;
readonly #events: TEventFactory;
readonly #aggregates: TAggregateFactory;
readonly #snapshot: "manual" | "auto";
readonly #hooks: EventStoreHooks<TEventFactory>;
declare readonly $events: TEventFactory["$events"];
declare readonly $records: TEventFactory["$events"][number]["$record"][];
constructor(config: EventStoreConfig<TEventFactory, TAggregateFactory, TEventStoreAdapter>) {
constructor(config: EventStoreConfig<TEventFactory, TEventStoreAdapter>) {
this.uuid = crypto.randomUUID();
this.#adapter = config.adapter;
this.#events = config.events;
this.#aggregates = config.aggregates.withStore(this);
this.#snapshot = config.snapshot ?? "manual";
this.#hooks = config.hooks ?? {};
}
@@ -113,55 +109,82 @@ export class EventStore<
|--------------------------------------------------------------------------------
*/
/**
* Get aggregate uninstantiated class.
*
* @param name - Aggregate name to retrieve.
*/
aggregate<TName extends TAggregateFactory["$aggregates"][number]["name"]>(
name: TName,
): Extract<TAggregateFactory["$aggregates"][number], { name: TName }> {
return this.#aggregates.get(name) as Extract<TAggregateFactory["$aggregates"][number], { name: TName }>;
}
readonly aggregate = {
/**
* Takes a list of aggregates and commits any pending events to the event store.
* Events are committed in order so its important to ensure that the aggregates
* are placed in the correct index position of the array.
*
* This method allows for a simpler way to commit many events over many
* aggregates in a single transaction. Ensuring atomicity of a larger group
* of events.
*
* @param aggregates - Aggregates to push events from.
* @param settings - Event settings which can modify insertion behavior.
*/
push: async (
aggregates: InstanceType<AggregateRootClass<TEventFactory>>[],
settings?: EventsInsertSettings,
): Promise<void> => {
const events: this["$events"][number]["$record"][] = [];
for (const aggregate of aggregates) {
events.push(...aggregate.toPending());
}
await this.pushManyEvents(events, settings);
for (const aggregate of aggregates) {
aggregate.flush();
}
},
/**
* Takes in an aggregate and commits any pending events to the event store.
*
* @param aggregate - Aggregate to push events from.
* @param settings - Event settings which can modify insertion behavior.
*/
async pushAggregate(
aggregate: InstanceType<TAggregateFactory["$aggregates"][number]>,
settings?: EventsInsertSettings,
): Promise<void> {
await aggregate.save(settings);
}
/**
* Get a new aggregate instance by a given stream.
*
* @param name - Aggregate to instantiate.
* @param stream - Stream to retrieve snapshot from.
*/
getByStream: async <TAggregate extends AggregateRootClass<TEventFactory>>(
aggregate: TAggregate,
stream: string,
): Promise<InstanceType<TAggregate> | undefined> => {
const reducer = makeAggregateReducer(this, aggregate);
const snapshot = await this.reduce({ name: aggregate.name, stream, reducer });
if (snapshot === undefined) {
return undefined;
}
return aggregate.from(this, snapshot as Unknown);
},
/**
* Takes a list of aggregates and commits any pending events to the event store.
* Events are committed in order so its important to ensure that the aggregates
* are placed in the correct index position of the array.
*
* This method allows for a simpler way to commit many events over many
* aggregates in a single transaction. Ensuring atomicity of a larger group
* of events.
*
* @param aggregates - Aggregates to push events from.
* @param settings - Event settings which can modify insertion behavior.
*/
async pushManyAggregates(
aggregates: InstanceType<TAggregateFactory["$aggregates"][number]>[],
settings?: EventsInsertSettings,
): Promise<void> {
const events: this["$events"][number]["$record"][] = [];
for (const aggregate of aggregates) {
events.push(...aggregate.toPending());
}
await this.pushManyEvents(events, settings);
for (const aggregate of aggregates) {
aggregate.flush();
}
}
/**
* Get a new aggregate instance by a given relation.
*
* @param name - Aggregate to instantiate.
* @param relation - Relation to retrieve snapshot from.
*/
getByRelation: async <TAggregate extends AggregateRootClass<TEventFactory>>(
aggregate: TAggregate,
relation: string,
): Promise<InstanceType<TAggregate> | undefined> => {
const reducer = makeAggregateReducer(this, aggregate);
const snapshot = await this.reduce({ name: aggregate.name, relation, reducer });
if (snapshot === undefined) {
return undefined;
}
return aggregate.from(this, snapshot as Unknown);
},
/**
* Instantiate a new aggreate.
*
* @param aggregate - Aggregate to instantiate.
* @param snapshot - Optional snapshot to instantiate aggregate with.
*/
from: <TAggregate extends AggregateRootClass<TEventFactory>>(
aggregate: TAggregate,
snapshot?: Unknown,
): InstanceType<TAggregate> => {
return aggregate.from(this, snapshot);
},
};
/*
|--------------------------------------------------------------------------------
@@ -341,43 +364,6 @@ export class EventStore<
return makeReducer<TEventFactory, TState>(foldFn, stateFn);
}
/**
* Make a new event reducer based on the events registered with the event store.
*
* @param aggregate - Aggregate class to create instance from.
*
* @example
* ```ts
* class Foo extends AggregateRoot<Event> {
* name: string = "";
*
* static #reducer = makeAggregateReducer(Foo);
*
* static async getById(fooId: string): Promise<Foo | undefined> {
* return eventStore.reduce({
* name: "foo",
* stream: "stream-id",
* reducer: this.#reducer,
* });
* }
*
* with(event) {
* switch (event.type) {
* case "FooCreated": {
* this.name = event.data.name;
* break;
* }
* }
* }
* });
* ```
*/
makeAggregateReducer<TAggregateRoot extends typeof AggregateRoot<TEventFactory>>(
aggregate: TAggregateRoot,
): Reducer<TEventFactory, InstanceType<TAggregateRoot>> {
return makeAggregateReducer<TEventFactory, TAggregateRoot>(aggregate);
}
/**
* Reduce events in the given stream to a entity state.
*
@@ -540,14 +526,9 @@ export class EventStore<
|--------------------------------------------------------------------------------
*/
type EventStoreConfig<
TEventFactory extends EventFactory,
TAggregateFactory extends AggregateFactory<TEventFactory>,
TEventStoreAdapter extends EventStoreAdapter<any>,
> = {
type EventStoreConfig<TEventFactory extends EventFactory, TEventStoreAdapter extends EventStoreAdapter<any>> = {
adapter: TEventStoreAdapter;
events: TEventFactory;
aggregates: TAggregateFactory;
snapshot?: "manual" | "auto";
hooks?: EventStoreHooks<TEventFactory>;
};
@@ -588,4 +569,4 @@ export type EventStoreHooks<TEventFactory extends EventFactory> = Partial<{
onError(error: unknown): Promise<void>;
}>;
export type AnyEventStore = EventStore<any, any, any>;
export type AnyEventStore = EventStore<any, any>;

View File

@@ -1,4 +1,4 @@
import z, { ZodType } from "zod/v4";
import z, { ZodType } from "zod";
import { EventValidationError } from "./errors.ts";
import { makeId } from "./nanoid.ts";

View File

@@ -1,6 +1,7 @@
import type { AggregateRoot } from "../libraries/aggregate.ts";
import type { Unknown } from "../types/common.ts";
import { EventFactory } from "./event-factory.ts";
import type { AnyEventStore } from "./event-store.ts";
/**
* Make an event reducer that produces a aggregate instance from resolved
@@ -11,13 +12,13 @@ import { EventFactory } from "./event-factory.ts";
export function makeAggregateReducer<
TEventFactory extends EventFactory,
TAggregateRoot extends typeof AggregateRoot<TEventFactory>,
>(aggregate: TAggregateRoot): Reducer<TEventFactory, InstanceType<TAggregateRoot>> {
>(store: AnyEventStore, aggregate: TAggregateRoot): Reducer<TEventFactory, InstanceType<TAggregateRoot>> {
return {
from(snapshot: Unknown) {
return aggregate.from(snapshot);
return aggregate.from(store, snapshot);
},
reduce(events: TEventFactory["$events"][number]["$record"][], snapshot?: Unknown) {
const instance = aggregate.from(snapshot);
const instance = aggregate.from(store, snapshot);
for (const event of events) {
instance.with(event);
}

View File

@@ -1,4 +1,4 @@
import { ZodError } from "zod/v4";
import { ZodError } from "zod";
export function toPrettyErrorLines(error: ZodError, padding: number = 0): string[] {
const lines: string[] = [];