feat: version 2 beta

This commit is contained in:
2025-04-25 22:39:47 +00:00
commit 1e58359905
75 changed files with 6899 additions and 0 deletions

View 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
View 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
View 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.`);
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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("");
}