feat: update aggregate implementation
This commit is contained in:
@@ -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 }>;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user