feat: version 2 beta
This commit is contained in:
74
libraries/aggregate-factory.ts
Normal file
74
libraries/aggregate-factory.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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 }>;
|
||||
}
|
||||
}
|
||||
168
libraries/aggregate.ts
Normal file
168
libraries/aggregate.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { AnyEventStore, EventsInsertSettings } from "../libraries/event-store.ts";
|
||||
import type { Unknown } from "../types/common.ts";
|
||||
import { EventFactory } from "./event-factory.ts";
|
||||
|
||||
/**
|
||||
* Represents an aggregate root in an event-sourced system.
|
||||
*
|
||||
* This abstract class serves as a base for domain aggregates that manage
|
||||
* state changes through events. It provides functionality for creating
|
||||
* instances from snapshots, handling pending events, and committing
|
||||
* changes to an event store.
|
||||
*
|
||||
* @template TEvent - The type of events associated with this aggregate.
|
||||
*/
|
||||
export abstract class AggregateRoot<TEventFactory extends EventFactory> {
|
||||
/**
|
||||
* Unique identifier allowing for easy indexing of aggregate lists.
|
||||
*/
|
||||
static readonly name: string;
|
||||
|
||||
/**
|
||||
* Event store to transact against.
|
||||
*/
|
||||
protected static _store?: AnyEventStore;
|
||||
|
||||
/**
|
||||
* List of pending records to push to the parent event store.
|
||||
*/
|
||||
#pending: TEventFactory["$events"][number]["$record"][] = [];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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.`);
|
||||
}
|
||||
return this._store;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the aggregate have pending events to submit to the event store.
|
||||
*/
|
||||
get isDirty(): boolean {
|
||||
return this.#pending.length > 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factories
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new aggregate instance with an optional snapshot. This method
|
||||
* exists as a unified way to create new aggregates from a event store
|
||||
* adapter and not really meant for aggregate creation outside of the
|
||||
* event store.
|
||||
*
|
||||
* @param snapshot - Snapshot to assign to the aggregate state.
|
||||
*/
|
||||
static from<TEventFactory extends EventFactory, TAggregateRoot extends typeof AggregateRoot<TEventFactory>>(
|
||||
this: TAggregateRoot,
|
||||
snapshot?: Unknown,
|
||||
): InstanceType<TAggregateRoot> {
|
||||
const instance = new (this as any)();
|
||||
if (snapshot !== undefined) {
|
||||
Object.assign(instance, snapshot);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Events
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Push a new event record to the pending list of events to commit to
|
||||
* a event store. This also submits the record to the `.with`
|
||||
* aggregate folder to update the aggregate state.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const foo = await eventStore.aggregate("foo");
|
||||
*
|
||||
* foo.push({
|
||||
* type: "foo:bar-set",
|
||||
* stream: foo.id,
|
||||
* data: { bar: "foobar" }
|
||||
* });
|
||||
*
|
||||
* await foo.save();
|
||||
*
|
||||
* @param event - Event to push into the pending commit pool.
|
||||
*/
|
||||
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);
|
||||
this.#pending.push(pending);
|
||||
this.with(pending);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and applies incoming events to update the aggregate state.
|
||||
*
|
||||
* @param record - Event record to fold.
|
||||
*/
|
||||
abstract with(record: TEventFactory["$events"][number]["$record"]): void;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mutators
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Saves all pending events to the attached event store.
|
||||
*
|
||||
* @param settings - Event insert settings.
|
||||
* @param flush - Empty the pending event list after event store push.
|
||||
*/
|
||||
async save(settings?: EventsInsertSettings, flush = true): Promise<this> {
|
||||
if (this.isDirty === false) {
|
||||
return this;
|
||||
}
|
||||
await this.$store.pushManyEvents(this.#pending, settings);
|
||||
if (flush === true) {
|
||||
this.flush();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all events from the aggregate #pending list.
|
||||
*/
|
||||
flush(): this {
|
||||
this.#pending = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Converters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the aggregate pending event record list. This allows for
|
||||
* extraction of the pending commit list so that it can be used in
|
||||
* event submission across multiple aggregates.
|
||||
*/
|
||||
toPending(): TEventFactory["$events"][number]["$record"][] {
|
||||
return this.#pending;
|
||||
}
|
||||
}
|
||||
|
||||
export type AggregateRootClass<TEventFactory extends EventFactory> = typeof AggregateRoot<TEventFactory>;
|
||||
122
libraries/errors.ts
Normal file
122
libraries/errors.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Error thrown when an expected event is missing from the event store.
|
||||
*
|
||||
* This occurs when an event type has not been registered or cannot be found
|
||||
* within the event store instance.
|
||||
*
|
||||
* @property type - The type of error, always `"EventMissingError"`.
|
||||
*/
|
||||
export class EventMissingError extends Error {
|
||||
readonly type = "EventMissingError";
|
||||
|
||||
constructor(type: string) {
|
||||
super(`EventStore Error: Event '${type}' has not been registered with the event store instance.`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Event Errors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Error thrown when an event fails validation checks.
|
||||
*
|
||||
* This error indicates that an invalid event was provided during an insertion
|
||||
* process.
|
||||
*
|
||||
* @property type - Type of error, always `"EventValidationError"`.
|
||||
* @property errors - List of issues during validation.
|
||||
*/
|
||||
export class EventValidationError extends Error {
|
||||
readonly type = "EventValidationError";
|
||||
|
||||
constructor(
|
||||
readonly event: any,
|
||||
readonly errors: string[],
|
||||
) {
|
||||
super([`✖ Failed to validate '${event.type}' event!`, ...errors].join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when an event fails to be inserted into the event store.
|
||||
*
|
||||
* This error occurs when an issue arises during the insertion of an
|
||||
* event into storage, such as a constraint violation or storage failure.
|
||||
*
|
||||
* @property type - The type of error, always `"EventInsertionError"`.
|
||||
*/
|
||||
export class EventInsertionError extends Error {
|
||||
readonly type = "EventInsertionError";
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Hybrid Logical Clock Errors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Error thrown when a forward time jump exceeds the allowed tolerance in a Hybrid Logical Clock (HLC).
|
||||
*
|
||||
* This error occurs when the system detects a time jump beyond the configured tolerance,
|
||||
* which may indicate clock synchronization issues in a distributed system.
|
||||
*
|
||||
* @property type - The type of error, always `"ForwardJumpError"`.
|
||||
* @property timejump - The detected forward time jump in milliseconds.
|
||||
* @property tolerance - The allowed maximum time jump tolerance in milliseconds.
|
||||
*/
|
||||
export class HLCForwardJumpError extends Error {
|
||||
readonly type = "ForwardJumpError";
|
||||
|
||||
constructor(
|
||||
readonly timejump: number,
|
||||
readonly tolerance: number,
|
||||
) {
|
||||
super(`HLC Violation: Detected a forward time jump of ${timejump}ms, which exceed the allowed tolerance of ${tolerance}ms.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when the received HLC timestamp is ahead of the system's wall time beyond the allowed offset.
|
||||
*
|
||||
* This error ensures that timestamps do not drift too far ahead of real time,
|
||||
* preventing inconsistencies in distributed event ordering.
|
||||
*
|
||||
* @property type - The type of error, always `"ClockOffsetError"`.
|
||||
* @property offset - The difference between the received time and the system's wall time in milliseconds.
|
||||
* @property maxOffset - The maximum allowed clock offset in milliseconds.
|
||||
*/
|
||||
export class HLCClockOffsetError extends Error {
|
||||
readonly type = "ClockOffsetError";
|
||||
|
||||
constructor(
|
||||
readonly offset: number,
|
||||
readonly maxOffset: number,
|
||||
) {
|
||||
super(`HLC Violation: Received time is ${offset}ms ahead of the wall time, exceeding the 'maxOffset' limit of ${maxOffset}ms.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when the Hybrid Logical Clock (HLC) wall time exceeds the defined maximum limit.
|
||||
*
|
||||
* This error prevents time overflow issues that could lead to incorrect event ordering
|
||||
* in a distributed system.
|
||||
*
|
||||
* @property type - The type of error, always `"WallTimeOverflowError"`.
|
||||
* @property time - The current HLC wall time in milliseconds.
|
||||
* @property maxTime - The maximum allowed HLC wall time in milliseconds.
|
||||
*/
|
||||
export class HLCWallTimeOverflowError extends Error {
|
||||
readonly type = "WallTimeOverflowError";
|
||||
|
||||
constructor(
|
||||
readonly time: number,
|
||||
readonly maxTime: number,
|
||||
) {
|
||||
super(`HLC Violation: Wall time ${time}ms exceeds the max time of ${maxTime}ms.`);
|
||||
}
|
||||
}
|
||||
53
libraries/event-factory.ts
Normal file
53
libraries/event-factory.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Event } from "./event.ts";
|
||||
|
||||
/**
|
||||
* Indexes a list of event factories for use with aggregates and event stores
|
||||
* when generating or accessing event functionality.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* import { event } from "@valkyr/event-store";
|
||||
* import z from "zod";
|
||||
*
|
||||
* const factory = new EventFactory([
|
||||
* event
|
||||
* .type("user:created")
|
||||
* .data(z.object({ name: z.string(), email: z.email() }))
|
||||
* .meta(z.object({ createdBy: z.string() })),
|
||||
* ]);
|
||||
*
|
||||
* export type Events = typeof factory.$events;
|
||||
* ```
|
||||
*/
|
||||
export class EventFactory<const TEvents extends Event[] = Event[]> {
|
||||
/**
|
||||
* Optimized event lookup index.
|
||||
*/
|
||||
readonly #index = new Map<TEvents[number]["state"]["type"], TEvents[number]>();
|
||||
|
||||
/**
|
||||
* Inferred type of the events registered with the factory.
|
||||
*/
|
||||
declare readonly $events: TEvents;
|
||||
|
||||
/**
|
||||
* Instantiate a new EventFactory with given list of supported events.
|
||||
*
|
||||
* @param events - Events to register with the factory.
|
||||
*/
|
||||
constructor(readonly events: TEvents) {
|
||||
for (const event of events) {
|
||||
this.#index.set(event.state.type, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered event from the factory.
|
||||
*
|
||||
* @param type - Event type to retrieve.
|
||||
*/
|
||||
get<TType extends TEvents[number]["state"]["type"]>(type: TType): Extract<TEvents[number], { state: { type: TType } }> {
|
||||
return this.#index.get(type) as Extract<TEvents[number], { state: { type: TType } }>;
|
||||
}
|
||||
}
|
||||
557
libraries/event-store.ts
Normal file
557
libraries/event-store.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* @module
|
||||
*
|
||||
* This module contains an abstract event store solution that can take a variety of
|
||||
* provider adapters to support multiple storage drivers.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { EventStore } from "@valkyr/event-store";
|
||||
* import { z } from "zod";
|
||||
*
|
||||
* const eventStore = new EventStore({
|
||||
* adapter: {
|
||||
* providers: {
|
||||
* event: new EventProvider(db),
|
||||
* relations: new RelationsProvider(db),
|
||||
* snapshot: new SnapshotProvider(db),
|
||||
* },
|
||||
* },
|
||||
* events: [
|
||||
* event
|
||||
* .type("user:created")
|
||||
* .data(
|
||||
* z.strictObject({
|
||||
* name: z.string(),
|
||||
* email: z.string().check(z.email())
|
||||
* }),
|
||||
* )
|
||||
* .meta(z.string()),
|
||||
* ],
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
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 { EventInsertionError, EventMissingError, EventValidationError } from "./errors.ts";
|
||||
import { EventStatus } from "./event.ts";
|
||||
import { EventFactory } from "./event-factory.ts";
|
||||
import type { InferReducerState, Reducer, ReducerLeftFold, ReducerState } from "./reducer.ts";
|
||||
import { makeAggregateReducer, makeReducer } from "./reducer.ts";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Event Store
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
> {
|
||||
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>) {
|
||||
this.#adapter = config.adapter;
|
||||
this.#events = config.events;
|
||||
this.#aggregates = config.aggregates.withStore(this);
|
||||
this.#snapshot = config.snapshot ?? "manual";
|
||||
this.#hooks = config.hooks ?? {};
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
get db(): TEventStoreAdapter["db"] {
|
||||
return this.#adapter.db;
|
||||
}
|
||||
|
||||
get events(): TEventStoreAdapter["providers"]["events"] {
|
||||
return this.#adapter.providers.events;
|
||||
}
|
||||
|
||||
get relations(): TEventStoreAdapter["providers"]["relations"] {
|
||||
return this.#adapter.providers.relations;
|
||||
}
|
||||
|
||||
get snapshots(): TEventStoreAdapter["providers"]["snapshots"] {
|
||||
return this.#adapter.providers.snapshots;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Event Handlers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
onEventsInserted(fn: EventStoreHooks<TEventFactory>["onEventsInserted"]) {
|
||||
this.#hooks.onEventsInserted = fn;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Aggregates
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Events
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Event factory producing a new event record from one of the events registered
|
||||
* with the event store instance.
|
||||
*
|
||||
* @param payload - Event payload to pass to an available factory.
|
||||
*/
|
||||
event<TType extends TEventFactory["$events"][number]["state"]["type"]>(
|
||||
payload: { type: TType } & Extract<TEventFactory["$events"][number], { state: { type: TType } }>["$payload"],
|
||||
): Extract<TEventFactory["$events"][number], { state: { type: TType } }>["$record"] {
|
||||
const event = this.#events.get((payload as any).type);
|
||||
if (event === undefined) {
|
||||
throw new Error(`Event '${(payload as any).type}' not found`);
|
||||
}
|
||||
return event.record(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an event record to the local event store database.
|
||||
*
|
||||
* @param record - Event record to insert.
|
||||
* @param settings - Event settings which can modify insertion behavior.
|
||||
*/
|
||||
async pushEvent(record: this["$events"][number]["$record"], settings: EventsInsertSettings = {}): Promise<void> {
|
||||
const event = this.#events.get(record.type);
|
||||
if (event === undefined) {
|
||||
throw new EventMissingError(record.type);
|
||||
}
|
||||
const validation = event.validate(record);
|
||||
if (validation.success === false) {
|
||||
throw new EventValidationError(record, validation.errors);
|
||||
}
|
||||
await this.events.insert(record).catch((error) => {
|
||||
throw new EventInsertionError(error.message);
|
||||
});
|
||||
if (settings.emit !== false) {
|
||||
await this.#hooks.onEventsInserted?.([record], settings).catch(this.#hooks.onError ?? console.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add many events in strict sequence to the events table.
|
||||
*
|
||||
* This method runs in a transaction and will fail all events if one or more
|
||||
* insertion failures occurs.
|
||||
*
|
||||
* @param records - List of event records to insert.
|
||||
* @param settings - Event settings which can modify insertion behavior.
|
||||
*/
|
||||
async pushManyEvents(records: this["$events"][number]["$record"][], settings: EventsInsertSettings = {}): Promise<void> {
|
||||
const events: this["$events"][number]["$record"][] = [];
|
||||
for (const record of records) {
|
||||
const event = this.#events.get(record.type);
|
||||
if (event === undefined) {
|
||||
throw new EventMissingError(record.type);
|
||||
}
|
||||
const validation = event.validate(record);
|
||||
if (validation.success === false) {
|
||||
throw new EventValidationError(record, validation.errors);
|
||||
}
|
||||
events.push(record);
|
||||
}
|
||||
await this.events.insertMany(events).catch((error) => {
|
||||
throw new EventInsertionError(error.message);
|
||||
});
|
||||
if (settings.emit !== false) {
|
||||
await this.#hooks.onEventsInserted?.(events, settings).catch(this.#hooks.onError ?? console.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the ability to check an incoming events status in relation to the local
|
||||
* ledger. This is to determine what actions to take upon the ledger based on the
|
||||
* current status.
|
||||
*
|
||||
* **Exists**
|
||||
*
|
||||
* References the existence of the event in the local ledger. It is determined by
|
||||
* looking at the recorded event id which should be unique to the entirety of the
|
||||
* ledger.
|
||||
*
|
||||
* **Outdated**
|
||||
*
|
||||
* References the events created relationship to the same event type in the
|
||||
* hosted stream. If another event of the same type in the streamis newer than
|
||||
* the provided event, the provided event is considered outdated.
|
||||
*/
|
||||
async getEventStatus(event: this["$events"][number]["$record"]): Promise<EventStatus> {
|
||||
const record = await this.events.getById(event.id);
|
||||
if (record) {
|
||||
return { exists: true, outdated: true };
|
||||
}
|
||||
return { exists: false, outdated: await this.events.checkOutdated(event) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve events from the events table.
|
||||
*
|
||||
* @param options - Read options. (Optional)
|
||||
*/
|
||||
async getEvents(options?: EventReadOptions): Promise<this["$events"][number]["$record"][]> {
|
||||
return this.events.get(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve events from the events table under the given streams.
|
||||
*
|
||||
* @param streams - Streams to retrieve events for.
|
||||
* @param options - Read options to pass to the provider. (Optional)
|
||||
*/
|
||||
async getEventsByStreams(streams: string[], options?: EventReadOptions): Promise<TEventFactory["$events"][number]["$record"][]> {
|
||||
return this.events.getByStreams(streams, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all events under the given relational keys.
|
||||
*
|
||||
* @param keys - Relational keys to retrieve events for.
|
||||
* @param options - Relational logic options. (Optional)
|
||||
*/
|
||||
async getEventsByRelations(keys: string[], options?: EventReadOptions): Promise<TEventFactory["$events"][number]["$record"][]> {
|
||||
const streamIds = await this.relations.getByKeys(keys);
|
||||
if (streamIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return this.events.getByStreams(streamIds, options);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Reducers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make a new event reducer based on the events registered with the event store.
|
||||
*
|
||||
* @param reducer - Reducer method to run over given events.
|
||||
* @param state - Initial state.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const reducer = eventStore.makeReducer<{ name: string }>((state, event) => {
|
||||
* switch (event.type) {
|
||||
* case "FooCreated": {
|
||||
* state.name = event.data.name;
|
||||
* break;
|
||||
* }
|
||||
* }
|
||||
* return state;
|
||||
* }, () => ({
|
||||
* name: ""
|
||||
* }));
|
||||
*
|
||||
* const state = await eventStore.reduce({ name: "foo:reducer", stream: "stream-id", reducer });
|
||||
* ```
|
||||
*/
|
||||
makeReducer<TState extends Unknown>(foldFn: ReducerLeftFold<TState, TEventFactory>, stateFn: ReducerState<TState>): Reducer<TEventFactory, TState> {
|
||||
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.
|
||||
*
|
||||
* @param query - Reducer query to resolve event state from.
|
||||
* @param pending - List of non comitted events to append to the server events.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* const state = await eventStore.reduce({ stream, reducer });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* const state = await eventStore.reduce({ relation: `foo:${foo}:bars`, reducer });
|
||||
* ```
|
||||
*
|
||||
* Reducers are created through the `.makeReducer` and `.makeAggregateReducer` method.
|
||||
*/
|
||||
async reduce<TReducer extends Reducer>(
|
||||
{ name, stream, relation, reducer, ...query }: ReduceQuery<TReducer>,
|
||||
pending: TEventFactory["$events"][number]["$record"][] = [],
|
||||
): Promise<ReturnType<TReducer["reduce"]> | undefined> {
|
||||
const id = stream ?? relation;
|
||||
|
||||
let state: InferReducerState<TReducer> | undefined;
|
||||
let cursor: string | undefined;
|
||||
|
||||
const snapshot = await this.getSnapshot(name, id);
|
||||
if (snapshot !== undefined) {
|
||||
cursor = snapshot.cursor;
|
||||
state = snapshot.state;
|
||||
}
|
||||
|
||||
const events = (
|
||||
stream !== undefined ? await this.getEventsByStreams([id], { ...query, cursor }) : await this.getEventsByRelations([id], { ...query, cursor })
|
||||
).concat(pending);
|
||||
|
||||
if (events.length === 0) {
|
||||
if (state !== undefined) {
|
||||
return reducer.from(state);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = reducer.reduce(events, state);
|
||||
if (this.#snapshot === "auto") {
|
||||
await this.snapshots.insert(name, id, events.at(-1)!.created, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Snapshots
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a new snapshot for the given stream/relation and reducer.
|
||||
*
|
||||
* @param query - Reducer query to create snapshot from.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await eventStore.createSnapshot({ stream, reducer });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await eventStore.createSnapshot({ relation: `foo:${foo}:bars`, reducer });
|
||||
* ```
|
||||
*/
|
||||
async createSnapshot<TReducer extends Reducer>({ name, stream, relation, reducer, ...query }: ReduceQuery<TReducer>): Promise<void> {
|
||||
const id = stream ?? relation;
|
||||
const events = stream !== undefined ? await this.getEventsByStreams([id], query) : await this.getEventsByRelations([id], query);
|
||||
if (events.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
await this.snapshots.insert(name, id, events.at(-1)!.created, reducer.reduce(events));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an entity state snapshot from the database. These are useful for when we
|
||||
* want to reduce the amount of events that has to be processed when fetching
|
||||
* state history for a reducer.
|
||||
*
|
||||
* @param streamOrRelation - Stream, or Relation to get snapshot for.
|
||||
* @param reducer - Reducer to get snapshot for.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const snapshot = await eventStore.getSnapshot("foo:reducer", stream);
|
||||
* console.log(snapshot);
|
||||
* // {
|
||||
* // cursor: "jxubdY-0",
|
||||
* // state: {
|
||||
* // foo: "bar"
|
||||
* // }
|
||||
* // }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const snapshot = await eventStore.getSnapshot("foo:reducer", `foo:${foo}:bars`);
|
||||
* console.log(snapshot);
|
||||
* // {
|
||||
* // cursor: "jxubdY-0",
|
||||
* // state: {
|
||||
* // count: 1
|
||||
* // }
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
async getSnapshot<TReducer extends Reducer, TState = InferReducerState<TReducer>>(
|
||||
name: string,
|
||||
streamOrRelation: string,
|
||||
): Promise<{ cursor: string; state: TState } | undefined> {
|
||||
const snapshot = await this.snapshots.getByStream(name, streamOrRelation);
|
||||
if (snapshot === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { cursor: snapshot.cursor, state: snapshot.state as TState };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a snapshot.
|
||||
*
|
||||
* @param streamOrRelation - Stream, or Relation to delete snapshot for.
|
||||
* @param reducer - Reducer to remove snapshot for.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await eventStore.deleteSnapshot("foo:reducer", stream);
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await eventStore.deleteSnapshot("foo:reducer", `foo:${foo}:bars`);
|
||||
* ```
|
||||
*/
|
||||
async deleteSnapshot(name: string, streamOrRelation: string): Promise<void> {
|
||||
await this.snapshots.remove(name, streamOrRelation);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type EventStoreConfig<
|
||||
TEventFactory extends EventFactory,
|
||||
TAggregateFactory extends AggregateFactory<TEventFactory>,
|
||||
TEventStoreAdapter extends EventStoreAdapter<any>,
|
||||
> = {
|
||||
adapter: TEventStoreAdapter;
|
||||
events: TEventFactory;
|
||||
aggregates: TAggregateFactory;
|
||||
snapshot?: "manual" | "auto";
|
||||
hooks?: EventStoreHooks<TEventFactory>;
|
||||
};
|
||||
|
||||
export type EventsInsertSettings = {
|
||||
/**
|
||||
* Should the event store emit events after successfull insertion.
|
||||
* This only takes false as value and by default events are always
|
||||
* projected.
|
||||
*/
|
||||
emit?: false;
|
||||
|
||||
/**
|
||||
* Batch key that can be used to group several events in a single
|
||||
* batched operation for performance sensitive handling.
|
||||
*/
|
||||
batch?: string;
|
||||
};
|
||||
|
||||
export type EventStoreHooks<TEventFactory extends EventFactory> = Partial<{
|
||||
/**
|
||||
* Triggered when `.pushEvent` and `.pushManyEvents` has completed successfully.
|
||||
*
|
||||
* @param records - List of event records inserted.
|
||||
* @param settings - Event insert settings used.
|
||||
*/
|
||||
onEventsInserted(records: TEventFactory["$events"][number]["$record"][], settings: EventsInsertSettings): Promise<void>;
|
||||
|
||||
/**
|
||||
* Triggered when an unhandled exception is thrown during `.pushEvent` and
|
||||
* `.pushManyEvents` hook.
|
||||
*
|
||||
* @param error - Error that was thrown.
|
||||
*/
|
||||
onError(error: unknown): Promise<void>;
|
||||
}>;
|
||||
|
||||
export type AnyEventStore = EventStore<any, any, any>;
|
||||
203
libraries/event.ts
Normal file
203
libraries/event.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import z, { ZodType } from "zod";
|
||||
|
||||
import { EventValidationError } from "./errors.ts";
|
||||
import { makeId } from "./nanoid.ts";
|
||||
import { getLogicalTimestamp } from "./time.ts";
|
||||
import { toPrettyErrorLines } from "./zod.ts";
|
||||
|
||||
export class Event<TEventState extends EventState = EventState> {
|
||||
declare readonly $record: EventRecord<TEventState>;
|
||||
declare readonly $payload: EventPayload<TEventState>;
|
||||
|
||||
constructor(readonly state: TEventState) {}
|
||||
|
||||
/**
|
||||
* Stores the recorded partial piece of data that makes up a larger aggregate
|
||||
* state.
|
||||
*
|
||||
* @param data - Schema used to parse and infer the data supported by the event.
|
||||
*/
|
||||
data<TData extends ZodType>(data: TData): Event<Omit<TEventState, "data"> & { data: TData }> {
|
||||
return new Event<Omit<TEventState, "data"> & { data: TData }>({ ...this.state, data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores additional meta data about the event that is not directly related
|
||||
* to the aggregate state.
|
||||
*
|
||||
* @param meta - Schema used to parse and infer the meta supported by the event.
|
||||
*/
|
||||
meta<TMeta extends ZodType>(meta: TMeta): Event<Omit<TEventState, "meta"> & { meta: TMeta }> {
|
||||
return new Event<Omit<TEventState, "meta"> & { meta: TMeta }>({ ...this.state, meta });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event record by combining the given event with additional metadata.
|
||||
* The resulting record can be stored in an event store.
|
||||
*
|
||||
* @param payload - The event to record.
|
||||
*/
|
||||
record(payload: EventPayload<TEventState>): EventRecord<TEventState> {
|
||||
const timestamp = getLogicalTimestamp();
|
||||
|
||||
const record = {
|
||||
id: makeId(),
|
||||
stream: payload.stream ?? makeId(),
|
||||
type: this.state.type,
|
||||
data: "data" in payload ? payload.data : null,
|
||||
meta: "meta" in payload ? payload.meta : null,
|
||||
created: timestamp,
|
||||
recorded: timestamp,
|
||||
} as any;
|
||||
|
||||
const validation = this.validate(record);
|
||||
if (validation.success === false) {
|
||||
throw new EventValidationError(record, validation.errors);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an event record and validates it against the event.
|
||||
*
|
||||
* @param record - Record to validate.
|
||||
*/
|
||||
validate(record: EventRecord<TEventState>): EventValidationResult {
|
||||
const errors = [];
|
||||
|
||||
if (record.type !== this.state.type) {
|
||||
errors.push(`✖ Event record '${record.type}' does not belong to '${this.state.type}' event.`);
|
||||
}
|
||||
|
||||
if (record.data !== null) {
|
||||
if (this.state.data === undefined) {
|
||||
errors.push(`✖ Event record '${record.type}' does not have a 'data' validator.`);
|
||||
} else {
|
||||
const result = this.state.data.safeParse(record.data);
|
||||
if (result.success === false) {
|
||||
errors.push(toPrettyErrorLines(result.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (record.meta !== null) {
|
||||
if (this.state.meta === undefined) {
|
||||
errors.push(`✖ Event record '${record.type}' does not have a 'meta' validator.`);
|
||||
} else {
|
||||
const result = this.state.meta.safeParse(record.meta);
|
||||
if (result.success === false) {
|
||||
errors.push(toPrettyErrorLines(result.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length !== 0) {
|
||||
return { success: false, errors };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export const event = {
|
||||
type<const TType extends string>(type: TType): Event<{ type: TType }> {
|
||||
return new Event<{ type: TType }>({ type });
|
||||
},
|
||||
};
|
||||
|
||||
type EventState = {
|
||||
type: string;
|
||||
data?: ZodType;
|
||||
meta?: ZodType;
|
||||
};
|
||||
|
||||
export type EventPayload<TEventState extends EventState> = { stream?: string } & (TEventState["data"] extends ZodType
|
||||
? { data: z.infer<TEventState["data"]> }
|
||||
: object) &
|
||||
(TEventState["meta"] extends ZodType ? { meta: z.infer<TEventState["meta"]> } : object);
|
||||
|
||||
type EventValidationResult =
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
errors: any[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Event that has been persisted to a event store solution.
|
||||
*/
|
||||
export type EventRecord<TEvent extends EventState = EventState> = {
|
||||
/**
|
||||
* A unique event identifier.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Event streams are used to group related events together. This identifier
|
||||
* is used to identify the stream to which the event belongs.
|
||||
*/
|
||||
stream: string;
|
||||
|
||||
/**
|
||||
* Type refers to the purpose of the event in a past tense descibing something
|
||||
* that has already happened.
|
||||
*/
|
||||
type: TEvent["type"];
|
||||
|
||||
/**
|
||||
* Key holding event data that can be used to update one or several read
|
||||
* models and used to generate aggregate state for the stream in which the
|
||||
* event belongs.
|
||||
*/
|
||||
data: TEvent["data"] extends ZodType ? z.infer<TEvent["data"]> : null;
|
||||
|
||||
/**
|
||||
* Key holding meta data that is not directly tied to read models or used
|
||||
* in aggregate states.
|
||||
*/
|
||||
meta: TEvent["meta"] extends ZodType ? z.infer<TEvent["meta"]> : null;
|
||||
|
||||
/**
|
||||
* An immutable hybrid logical clock timestamp representing the wall time when
|
||||
* the event was created.
|
||||
*
|
||||
* This value is used to identify the date of its creation as well as a sorting
|
||||
* key when performing reduction logic to generate aggregate state for the
|
||||
* stream in which the event belongs.
|
||||
*/
|
||||
created: string;
|
||||
|
||||
/**
|
||||
* A mutable hybrid logical clock timestamp representing the wall time when the
|
||||
* event was recorded to the local **event ledger** _(database)_ as opposed to
|
||||
* when the event was actually created.
|
||||
*
|
||||
* This value is used when performing event synchronization between two
|
||||
* different event ledgers.
|
||||
*/
|
||||
recorded: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Status of an event and how it relates to other events in the aggregate
|
||||
* stream it has been recorded.
|
||||
*/
|
||||
export type EventStatus = {
|
||||
/**
|
||||
* Does the event already exist in the containing stream. This is an
|
||||
* optimization flag so that we can potentially ignore the processing of the
|
||||
* event if it already exists.
|
||||
*/
|
||||
exists: boolean;
|
||||
|
||||
/**
|
||||
* Is there another event in the stream of the same type that is newer than
|
||||
* the provided event. This is passed into projectors so that they can
|
||||
* route the event to the correct projection handlers.
|
||||
*
|
||||
* @see {@link Projection [once|on|all]}
|
||||
*/
|
||||
outdated: boolean;
|
||||
};
|
||||
122
libraries/hlc.ts
Normal file
122
libraries/hlc.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { HLCClockOffsetError, HLCForwardJumpError, HLCWallTimeOverflowError } from "./errors.ts";
|
||||
import { Timestamp } from "./timestamp.ts";
|
||||
|
||||
export class HLC {
|
||||
time: typeof getTime;
|
||||
|
||||
maxTime: number;
|
||||
maxOffset: number;
|
||||
|
||||
timeUpperBound: number;
|
||||
toleratedForwardClockJump: number;
|
||||
|
||||
last: Timestamp;
|
||||
|
||||
constructor(
|
||||
{ time = getTime, maxOffset = 0, timeUpperBound = 0, toleratedForwardClockJump = 0, last }: Options = {},
|
||||
) {
|
||||
this.time = time;
|
||||
this.maxTime = timeUpperBound > 0 ? timeUpperBound : Number.MAX_SAFE_INTEGER;
|
||||
this.maxOffset = maxOffset;
|
||||
this.timeUpperBound = timeUpperBound;
|
||||
this.toleratedForwardClockJump = toleratedForwardClockJump;
|
||||
this.last = new Timestamp(this.time());
|
||||
if (last) {
|
||||
this.last = Timestamp.bigger(new Timestamp(last.time), this.last);
|
||||
}
|
||||
}
|
||||
|
||||
now(): Timestamp {
|
||||
return this.update(this.last);
|
||||
}
|
||||
|
||||
update(other: Timestamp): Timestamp {
|
||||
this.last = this.#getTimestamp(other);
|
||||
return this.last;
|
||||
}
|
||||
|
||||
#getTimestamp(other: Timestamp): Timestamp {
|
||||
const [time, logical] = this.#getTimeAndLogicalValue(other);
|
||||
if (!this.#validUpperBound(time)) {
|
||||
throw new HLCWallTimeOverflowError(time, logical);
|
||||
}
|
||||
return new Timestamp(time, logical);
|
||||
}
|
||||
|
||||
#getTimeAndLogicalValue(other: Timestamp): [number, number] {
|
||||
const last = Timestamp.bigger(other, this.last);
|
||||
const time = this.time();
|
||||
if (this.#validOffset(last, time)) {
|
||||
return [time, 0];
|
||||
}
|
||||
return [last.time, last.logical + 1];
|
||||
}
|
||||
|
||||
#validOffset(last: Timestamp, time: number): boolean {
|
||||
const offset = last.time - time;
|
||||
if (!this.#validForwardClockJump(offset)) {
|
||||
throw new HLCForwardJumpError(-offset, this.toleratedForwardClockJump);
|
||||
}
|
||||
if (!this.#validMaxOffset(offset)) {
|
||||
throw new HLCClockOffsetError(offset, this.maxOffset);
|
||||
}
|
||||
if (offset < 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#validForwardClockJump(offset: number): boolean {
|
||||
if (this.toleratedForwardClockJump > 0 && -offset > this.toleratedForwardClockJump) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#validMaxOffset(offset: number): boolean {
|
||||
if (this.maxOffset > 0 && offset > this.maxOffset) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#validUpperBound(time: number): boolean {
|
||||
return time < this.maxTime;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return Object.freeze({
|
||||
maxOffset: this.maxOffset,
|
||||
timeUpperBound: this.timeUpperBound,
|
||||
toleratedForwardClockJump: this.toleratedForwardClockJump,
|
||||
last: this.last.toJSON(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function getTime(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Options = {
|
||||
time?: typeof getTime;
|
||||
maxOffset?: number;
|
||||
timeUpperBound?: number;
|
||||
toleratedForwardClockJump?: number;
|
||||
last?: {
|
||||
time: number;
|
||||
logical: number;
|
||||
};
|
||||
};
|
||||
10
libraries/nanoid.ts
Normal file
10
libraries/nanoid.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
/**
|
||||
* Generate a new nanoid.
|
||||
*
|
||||
* @param size - Size of the id. Default: 11
|
||||
*/
|
||||
export function makeId(size: number = 11): string {
|
||||
return nanoid(size);
|
||||
}
|
||||
271
libraries/projector.ts
Normal file
271
libraries/projector.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { Subscription } from "../types/common.ts";
|
||||
import type {
|
||||
BatchedProjectionHandler,
|
||||
BatchedProjectorListeners,
|
||||
ProjectionFilter,
|
||||
ProjectionHandler,
|
||||
ProjectionStatus,
|
||||
ProjectorListenerFn,
|
||||
ProjectorListeners,
|
||||
ProjectorMessage,
|
||||
} from "../types/projector.ts";
|
||||
import { EventFactory } from "./event-factory.ts";
|
||||
import { Queue } from "./queue.ts";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Filters
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const FILTER_ONCE = Object.freeze<ProjectionFilter>({
|
||||
allowHydratedEvents: false,
|
||||
allowOutdatedEvents: false,
|
||||
});
|
||||
|
||||
const FILTER_CONTINUOUS = Object.freeze<ProjectionFilter>({
|
||||
allowHydratedEvents: true,
|
||||
allowOutdatedEvents: false,
|
||||
});
|
||||
|
||||
const FILTER_ALL = Object.freeze<ProjectionFilter>({
|
||||
allowHydratedEvents: true,
|
||||
allowOutdatedEvents: true,
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Projector
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Manages event projections by handling and distributing events to registered listeners.
|
||||
*
|
||||
* The `Projector` class is responsible for processing event records and invoking
|
||||
* projection handlers based on predefined filters. It supports different projection
|
||||
* patterns, including one-time projections, continuous projections, and catch-all projections.
|
||||
* Additionally, it enables batched event processing for optimized handling of multiple events.
|
||||
*
|
||||
* @template TEventRecord - TType of event records processed by this projector.
|
||||
*/
|
||||
export class Projector<TEventFactory extends EventFactory = EventFactory> {
|
||||
#listeners: ProjectorListeners<TEventFactory["$events"][number]["$record"]> = {};
|
||||
#batchedListeners: BatchedProjectorListeners<TEventFactory["$events"][number]["$record"]> = {};
|
||||
#queues: {
|
||||
[stream: string]: Queue<ProjectorMessage<TEventFactory["$events"][number]["$record"]>>;
|
||||
} = {};
|
||||
|
||||
constructor() {
|
||||
this.push = this.push.bind(this);
|
||||
}
|
||||
|
||||
#makeQueue(stream: string) {
|
||||
this.#queues[stream] = new Queue(
|
||||
async ({ record, status }) => {
|
||||
return Promise.all(Array.from(this.#listeners[record.type as string] || []).map((fn) => fn(record, status)));
|
||||
},
|
||||
{
|
||||
onDrained: () => {
|
||||
delete this.#queues[stream];
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async push(record: TEventFactory["$events"][number]["$record"], status: ProjectionStatus): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
if (this.#queues[record.stream] === undefined) {
|
||||
this.#makeQueue(record.stream);
|
||||
}
|
||||
this.#queues[record.stream].push({ record, status }, resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
async pushMany(key: string, records: TEventFactory["$events"][number]["$record"][]): Promise<void> {
|
||||
await Promise.all(Array.from(this.#batchedListeners[key] || []).map((fn) => fn(records)));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Handlers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a batched projection handler taking in a list of events inserted under
|
||||
* a specific batched key.
|
||||
*
|
||||
* @param key - Batch key being projected.
|
||||
* @param handler - Handler method to execute when events are projected.
|
||||
*/
|
||||
batch(key: string, handler: BatchedProjectionHandler<TEventFactory["$events"][number]["$record"]>): Subscription {
|
||||
const listeners = (this.#batchedListeners[key] ?? (this.#batchedListeners[key] = new Set())).add(handler);
|
||||
return {
|
||||
unsubscribe() {
|
||||
listeners.delete(handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single run projection handler.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This method tells the projection that an event is only ever processed when
|
||||
* the event is originating directly from the local event store. A useful
|
||||
* pattern for when you want the event handler to submit data to a third
|
||||
* party service such as sending an email or submitting third party orders.
|
||||
*
|
||||
* We disallow `hydrate` and `outdated` as these events represents events
|
||||
* that has already been processed.
|
||||
*
|
||||
* @param type - Event type being projected.
|
||||
* @param handler - Handler method to execute when event is projected.
|
||||
*/
|
||||
once<
|
||||
TType extends TEventFactory["$events"][number]["$record"]["type"],
|
||||
TRecord extends TEventFactory["$events"][number]["$record"] = Extract<TEventFactory["$events"][number]["$record"], { type: TType }>,
|
||||
TSuccessData extends Record<string, any> | void = void,
|
||||
>(
|
||||
type: TType,
|
||||
handler: ProjectionHandler<TRecord, TSuccessData>,
|
||||
effects: TSuccessData extends void
|
||||
? {
|
||||
onError(res: { error: unknown; record: TRecord }): Promise<void>;
|
||||
onSuccess(res: { record: TRecord }): Promise<void>;
|
||||
}
|
||||
: {
|
||||
onError(res: { error: unknown; record: TRecord }): Promise<void>;
|
||||
onSuccess(res: { data: TSuccessData; record: TRecord }): Promise<void>;
|
||||
},
|
||||
): Subscription {
|
||||
return this.#subscribe(type, FILTER_ONCE, handler as any, effects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a continuous projection handler.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This method tells the projection to allow events directly from the event
|
||||
* store as well as events coming through hydration via sync, manual or
|
||||
* automatic stream rehydration operations. This is the default pattern
|
||||
* used for most events. This is where you usually project the latest data
|
||||
* to your read side models and data stores.
|
||||
*
|
||||
* We allow `hydrate` as they serve to keep the read side up to date with
|
||||
* the latest events. We disallow `outdated` as we do not want the latest
|
||||
* data to be overridden by outdated ones.
|
||||
*
|
||||
* NOTE! The nature of this pattern means that outdated events are never
|
||||
* run by this projection. Make sure to handle `outdated` events if you
|
||||
* have processing requirements that needs to know about every unknown
|
||||
* events that has occurred in the event stream.
|
||||
*
|
||||
* @param type - Event type being projected.
|
||||
* @param handler - Handler method to execute when event is projected.
|
||||
*/
|
||||
on<
|
||||
TType extends TEventFactory["$events"][number]["$record"]["type"],
|
||||
TRecord extends TEventFactory["$events"][number]["$record"] = Extract<TEventFactory["$events"][number]["$record"], { type: TType }>,
|
||||
>(type: TType, handler: ProjectionHandler<TRecord>): Subscription {
|
||||
return this.#subscribe(type, FILTER_CONTINUOUS, handler as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a catch all projection handler.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This method is a catch all for events that does not fall under the
|
||||
* stricter definitions of once and on patterns. This is a good place
|
||||
* to deal with data that does not depend on a strict order of events.
|
||||
*
|
||||
* @param type - Event type being projected.
|
||||
* @param handler - Handler method to execute when event is projected.
|
||||
*/
|
||||
all<
|
||||
TType extends TEventFactory["$events"][number]["$record"]["type"],
|
||||
TRecord extends TEventFactory["$events"][number]["$record"] = Extract<TEventFactory["$events"][number]["$record"], { type: TType }>,
|
||||
>(type: TType, handler: ProjectionHandler<TRecord>): Subscription {
|
||||
return this.#subscribe(type, FILTER_ALL, handler as any);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a event subscription against given type with assigned filter and handler.
|
||||
*
|
||||
* @param type - Event type to listen for.
|
||||
* @param filter - Projection filter to validate against.
|
||||
* @param handler - Handler to execute.
|
||||
*/
|
||||
#subscribe(
|
||||
type: string,
|
||||
filter: ProjectionFilter,
|
||||
handler: ProjectionHandler<TEventFactory["$events"][number]["$record"]>,
|
||||
effects?: {
|
||||
onError(res: { error: unknown; record: TEventFactory["$events"][number]["$record"] }): Promise<void>;
|
||||
onSuccess(res: { data?: unknown; record: TEventFactory["$events"][number]["$record"] }): Promise<void>;
|
||||
},
|
||||
): { unsubscribe: () => void } {
|
||||
return {
|
||||
unsubscribe: this.#addEventListener(type, async (record, state) => {
|
||||
if (this.#hasValidState(filter, state)) {
|
||||
await handler(record)
|
||||
.then((data: unknown) => {
|
||||
effects?.onSuccess({ data, record });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (effects !== undefined) {
|
||||
effects.onError({ error, record });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new event listener to handle incoming projection requests.
|
||||
*
|
||||
* @param type - Event type to listen for.
|
||||
* @param fn - Listener fn to execute.
|
||||
*/
|
||||
#addEventListener(type: string, fn: ProjectorListenerFn<TEventFactory["$events"][number]["$record"]>): () => void {
|
||||
const listeners = (this.#listeners[type] ?? (this.#listeners[type] = new Set())).add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the projection filter is compatible with the provided state.
|
||||
*
|
||||
* @param filter - Projection filter to match against.
|
||||
* @param state - Projection state to validate.
|
||||
*/
|
||||
#hasValidState(filter: ProjectionFilter, { hydrated, outdated }: ProjectionStatus) {
|
||||
if (filter.allowHydratedEvents === false && hydrated === true) {
|
||||
return false;
|
||||
}
|
||||
if (filter.allowOutdatedEvents === false && outdated === true) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
100
libraries/queue.ts
Normal file
100
libraries/queue.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export class Queue<T> {
|
||||
status: Status;
|
||||
|
||||
#queue: Message<T>[];
|
||||
#handle: Handler<T>;
|
||||
#hooks: Hooks;
|
||||
|
||||
constructor(handler: Handler<T>, hooks: Hooks = {}) {
|
||||
this.status = "idle";
|
||||
this.#queue = [];
|
||||
this.#handle = handler;
|
||||
this.#hooks = hooks;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
is(status: Status): boolean {
|
||||
return this.status === status;
|
||||
}
|
||||
|
||||
push(message: T, resolve: MessagePromise["resolve"], reject: MessagePromise["reject"]): this {
|
||||
this.#queue.push({ message, resolve, reject });
|
||||
this.#process();
|
||||
return this;
|
||||
}
|
||||
|
||||
flush(filter?: Filter<Message<T>>): this {
|
||||
if (filter) {
|
||||
this.#queue = this.#queue.filter(filter);
|
||||
} else {
|
||||
this.#queue = [];
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Processor
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async #process(): Promise<this> {
|
||||
if (this.is("working")) {
|
||||
return this;
|
||||
}
|
||||
|
||||
this.#setStatus("working");
|
||||
|
||||
const job = this.#queue.shift();
|
||||
if (!job) {
|
||||
return this.#setStatus("drained");
|
||||
}
|
||||
|
||||
this.#handle(job.message)
|
||||
.then(job.resolve)
|
||||
.catch(job.reject)
|
||||
.finally(() => {
|
||||
this.#setStatus("idle").#process();
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
#setStatus(value: Status): this {
|
||||
this.status = value;
|
||||
if (value === "drained") {
|
||||
this.#hooks.onDrained?.();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type Status = "idle" | "working" | "drained";
|
||||
|
||||
type Handler<T> = (message: T) => Promise<any> | Promise<any[]>;
|
||||
|
||||
type Hooks = {
|
||||
onDrained?: () => void;
|
||||
};
|
||||
|
||||
type Message<T> = {
|
||||
message: T;
|
||||
} & MessagePromise;
|
||||
|
||||
type MessagePromise = {
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason?: any) => void;
|
||||
};
|
||||
|
||||
type Filter<T> = (job: T) => boolean;
|
||||
90
libraries/reducer.ts
Normal file
90
libraries/reducer.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { AggregateRoot } from "../libraries/aggregate.ts";
|
||||
import type { Unknown } from "../types/common.ts";
|
||||
import { EventFactory } from "./event-factory.ts";
|
||||
|
||||
/**
|
||||
* Make an event reducer that produces a aggregate instance from resolved
|
||||
* events.
|
||||
*
|
||||
* @param aggregate - Aggregate to instantiate and create an instance of.
|
||||
*/
|
||||
export function makeAggregateReducer<TEventFactory extends EventFactory, TAggregateRoot extends typeof AggregateRoot<TEventFactory>>(
|
||||
aggregate: TAggregateRoot,
|
||||
): Reducer<TEventFactory, InstanceType<TAggregateRoot>> {
|
||||
return {
|
||||
from(snapshot: Unknown) {
|
||||
return aggregate.from(snapshot);
|
||||
},
|
||||
reduce(events: TEventFactory["$events"][number]["$record"][], snapshot?: Unknown) {
|
||||
const instance = aggregate.from(snapshot);
|
||||
for (const event of events) {
|
||||
instance.with(event);
|
||||
}
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an event reducer that produces a state based on resolved events.
|
||||
*
|
||||
* @param foldFn - Method which handles the event reduction.
|
||||
* @param stateFn - Default state factory.
|
||||
*/
|
||||
export function makeReducer<TEventFactory extends EventFactory, TState extends Unknown>(
|
||||
foldFn: ReducerLeftFold<TState, TEventFactory>,
|
||||
stateFn: ReducerState<TState>,
|
||||
): Reducer<TEventFactory, TState> {
|
||||
return {
|
||||
from(snapshot: TState) {
|
||||
return snapshot;
|
||||
},
|
||||
reduce(events: TEventFactory["$events"][number]["$record"][], snapshot?: TState) {
|
||||
return events.reduce(foldFn, snapshot ?? (stateFn() as TState));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Reducer<TEventFactory extends EventFactory = EventFactory, TState extends Record<string, unknown> | AggregateRoot<TEventFactory> = any> = {
|
||||
/**
|
||||
* Return result directly from a snapshot that does not have any subsequent
|
||||
* events to fold onto a state.
|
||||
*
|
||||
* @param snapshot - Snapshot of a reducer state.
|
||||
*/
|
||||
from(snapshot: Unknown): TState;
|
||||
|
||||
/**
|
||||
* Take in a list of events, and return a state from the given events.
|
||||
*
|
||||
* @param events - Events to reduce.
|
||||
* @param snapshot - Initial snapshot state to apply to the reducer.
|
||||
*/
|
||||
reduce(events: TEventFactory["$events"][number]["$record"][], snapshot?: Unknown): TState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Take an event, and fold it onto the given state.
|
||||
*
|
||||
* @param state - State to fold onto.
|
||||
* @param event - Event to fold from.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const events = [...events];
|
||||
* const state = events.reduce((state, event) => {
|
||||
* state.foo = event.data.foo;
|
||||
* return state;
|
||||
* }, {
|
||||
* foo: ""
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export type ReducerLeftFold<TState extends Record<string, unknown> = any, TEventFactory extends EventFactory = EventFactory> = (
|
||||
state: TState,
|
||||
event: TEventFactory["$events"][number]["$record"],
|
||||
) => TState;
|
||||
|
||||
export type ReducerState<TState extends Unknown> = () => TState;
|
||||
|
||||
export type InferReducerState<TReducer> = TReducer extends Reducer<infer _, infer TState> ? TState : never;
|
||||
40
libraries/time.ts
Normal file
40
libraries/time.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { HLC } from "./hlc.ts";
|
||||
import { Timestamp } from "./timestamp.ts";
|
||||
|
||||
const clock = new HLC();
|
||||
|
||||
/**
|
||||
* Get a date object from given event meta timestamp.
|
||||
*
|
||||
* @param timestamp - Event meta timestamp.
|
||||
*/
|
||||
export function getDate(timestamp: string): Date {
|
||||
return new Date(getUnixTimestamp(timestamp));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logical timestamp based on current time.
|
||||
*/
|
||||
export function getLogicalTimestamp(): string {
|
||||
const ts = clock.now().toJSON();
|
||||
return `${ts.time}-${String(ts.logical).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timestamp instance from provided logical timestamp.
|
||||
*
|
||||
* @param ts - Logical timestamp to convert.
|
||||
*/
|
||||
export function getTimestamp(ts: string): Timestamp {
|
||||
const [time, logical] = ts.split("-");
|
||||
return new Timestamp(time, Number(logical));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unix timestamp value from provided logical timestamp.
|
||||
*
|
||||
* @param ts - Logical timestamp to convert.
|
||||
*/
|
||||
export function getUnixTimestamp(ts: string): number {
|
||||
return getTimestamp(ts).time;
|
||||
}
|
||||
49
libraries/timestamp.ts
Normal file
49
libraries/timestamp.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const RADIX = 36;
|
||||
|
||||
export class Timestamp {
|
||||
readonly time: number;
|
||||
readonly logical: number;
|
||||
|
||||
constructor(time: TimeLike, logical = 0) {
|
||||
this.time = typeof time === "string" ? parseInt(time, RADIX) : time;
|
||||
this.logical = logical;
|
||||
}
|
||||
|
||||
static bigger(a: Timestamp, b: Timestamp): Timestamp {
|
||||
return a.compare(b) === -1 ? b : a;
|
||||
}
|
||||
|
||||
encode(): string {
|
||||
return this.time.toString(RADIX);
|
||||
}
|
||||
|
||||
compare(other: Timestamp): 1 | 0 | -1 {
|
||||
if (this.time > other.time) {
|
||||
return 1;
|
||||
}
|
||||
if (this.time < other.time) {
|
||||
return -1;
|
||||
}
|
||||
if (this.logical > other.logical) {
|
||||
return 1;
|
||||
}
|
||||
if (this.logical < other.logical) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
toJSON(): TimestampJSON {
|
||||
return Object.freeze({
|
||||
time: this.encode(),
|
||||
logical: this.logical,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type TimeLike = string | number;
|
||||
|
||||
type TimestampJSON = {
|
||||
readonly time: string;
|
||||
readonly logical: number;
|
||||
};
|
||||
33
libraries/zod.ts
Normal file
33
libraries/zod.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export function toPrettyErrorLines(error: ZodError, padding: number = 0): string[] {
|
||||
const lines: string[] = [];
|
||||
const margin = " ".repeat(padding);
|
||||
const issues = [...error.issues].sort((a, b) => a.path.length - b.path.length);
|
||||
for (const issue of issues) {
|
||||
lines.push(`${margin}✖ ${issue.message}`);
|
||||
if (issue.path?.length) {
|
||||
lines.push(`${margin} → at ${toDotPath(issue.path)}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function toDotPath(path: (string | number | symbol)[]): string {
|
||||
const segs: string[] = [];
|
||||
for (const seg of path) {
|
||||
if (typeof seg === "number") {
|
||||
segs.push(`[${seg}]`);
|
||||
} else if (typeof seg === "symbol") {
|
||||
segs.push(`["${String(seg)}"]`);
|
||||
} else if (seg.includes(".")) {
|
||||
segs.push(`["${seg}"]`);
|
||||
} else {
|
||||
if (segs.length) {
|
||||
segs.push(".");
|
||||
}
|
||||
segs.push(seg);
|
||||
}
|
||||
}
|
||||
return segs.join("");
|
||||
}
|
||||
Reference in New Issue
Block a user