commit 1e58359905a6ef2b55bfb36e429c781221e0ceed Author: kodemon Date: Fri Apr 25 22:39:47 2025 +0000 feat: version 2 beta diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..64a9cd2 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,46 @@ +name: Publish + +on: + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Deno + uses: maximousblk/setup-deno@v2 + + - name: Setup Node.JS + uses: actions/setup-node@v4 + with: + node-version: 22 + + - run: deno install + - run: deno task lint + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Deno + uses: maximousblk/setup-deno@v2 + + - run: deno install + - run: deno task test + + publish: + runs-on: ubuntu-latest + needs: [lint, test] + + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Publish package + run: npx jsr publish \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fb101be --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Test + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Deno + uses: maximousblk/setup-deno@v2 + + - name: Setup Node.JS + uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: deno install + - run: deno task lint + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Deno + uses: maximousblk/setup-deno@v2 + + - run: deno install + - run: deno task test + - run: deno task test:publish \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..691d217 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b2bb4f6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "deno.enable": true, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9e58d10 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +MIT License + +Copyright 2016-2025 Christoffer Rødvik. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the +Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..414d818 --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +

+ +

+ +# Event Store + +Event store solution written in deno for use in TypeScript projects to manage and distribute events from a central +repository to one or more distibuted services. + +## Quick Start + +The following provides a quick introduction on how to get started. + +### Configs + +Events are defined in `json` configuration files which we print to a generated `events.ts` file that is used by the +event store instance we are using. To do this, start by creating a new folder that will house our event configurations. + +```sh +$ mkdir events +$ cd events +``` + +Now add a new event configuration file. + +```sh +$ touch user-created.json +``` + +Open the file and add the event details. + +```json +{ + "event": { + "type": "user:created", + "data": { + "name": { + "type": "object", + "properties": { + "given": { + "type": "string" + }, + "family": { + "type": "string" + } + } + }, + "email": { + "type": "string" + } + }, + "meta": { + "auditor": { + "type": "string" + } + } + } +} +``` + +### Generate + +To create our `events.ts` file we have to run our configurations through our event printer. + +```ts +import { printEvents } from "@valkyr/event-store"; + +await printEvents({ + inputs: ["./configs/events"], + outputs: ["./generated/events.ts"], +}); +``` + +### Event Store + +Once we have defined our configs and printed our events we create a new event store instance. Currently we have support +for `sqlite`, `postgres`, and `valkyr/db` which all works the same way. So for this example we will use the `sqlite` +store. + + - Browser _(TODO)_ + - Mongo _(TODO)_ + - [Postgres](./adapters/postgres) + +### Reducers + +Event reducers takes a entity stream and reduces it to a wanted state. This is required when we want to perform write +side business logic on the current state of our streams. Using read stores for this is not ideal as the read side data +may not be up to date. + +```ts +import { makeReducer } from "@valkyr/event-store"; + +import type { EventRecord } from "./generated/events.ts"; + +const reducer = makeReducer<{ + name: string; + email: string; +}, EventRecord>((state, event) => { + switch (event.type) { + case "user:created": { + state.name = `${event.data.name.given} ${event.data.name.family}`; + state.email = event.data.email; + break; + } + case "user:email-set": { + state.email = event.data.email; + break; + } + } + return state; +}, "user", () => ({ + name: "", + email: "", +})); +``` + +### Aggreates + +Event aggregates takes a entity stream and reduces it to a wanted state. It works on the same conceptual grounds as +the standard reducer but resolved states using an aggregate instead of folding onto a state object. + +The benefit of this is that we can create various helper methods on the aggregate that can help us navigate and +query the aggregated state. + +```ts +import { AggregateRoot, makeAggregateReducer } from "@valkyr/event-store"; + +import type { EventRecord } from "./generated/events.ts"; +import { eventStore } from "./event-store.ts"; + +export class User extends AggregateRoot { + name!: Name; + email!: string; + + // ------------------------------------------------------------------------- + // Factories + // ------------------------------------------------------------------------- + + static #reducer = makeAggregateReducer(User, "user"); + + static async getById(userId: string): Promise { + return eventStore.reduce({ stream: userId, reducer: this.#reducer }); + } + + // ------------------------------------------------------------------------- + // Folder + // ------------------------------------------------------------------------- + + with(event: EventRecord) { + switch (event.type) { + case "user:created": { + this.name = event.data.name; + this.email = event.data.email; + break; + } + case "user:email-set": { + this.email = event.data.email; + break; + } + } + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + fullName() { + return `${this.name.given} ${this.name.family}`; + } +} + +type Name = { + given: string; + family: string; +} +``` + +### Projectors + +Projectors serves as a bridge between the write side and read side of your application. Think of them as event handlers +that listens for an event and creates new read side records by pushing that data to one or more data stores or apis +which is queried by your users. + +A projector is registered for a specific event type, and can have multiple handlers. They also come with three different +types of listeners, `once`, `on`, and `all`. + +```ts +import { projector } from "./event-store.ts"; + +projector.on("user:created", async (record) => { + // do something with the event record ... +}); +``` + +### Hydration in Event Processing + +When handling events in a distributed system or during event replay operations, it is important to differentiate between **new events** and **rehydrated events**. + +- **New Events (`hydrate: false`)**: These events are being processed for the first time. They will trigger all projection handlers, including `.once()`, `.on()`, and `.all()`. +- **Rehydrated Events (`hydrate: true`)**: These events are being replayed, either as part of a stream synchronization, system recovery, or reprocessing in a distributed environment. They **will not trigger** `.once()` handlers to avoid redundant side effects but will still be processed by `.on()` and `.all()` handlers where applicable. + +This mechanism ensures that critical one-time operations (such as sending emails or initiating external API calls) are **not repeated** unnecessarily while still allowing stateful projections to update their read models correctly. + + +#### `.once("user:created", (event) => Promise)` + +This handler 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. + +#### `.on("user:created", (event) => Promise)` + +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. + +#### `.all("user:created", (event) => Promise)` + +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. diff --git a/adapters/browser/adapter.ts b/adapters/browser/adapter.ts new file mode 100644 index 0000000..cc70d0b --- /dev/null +++ b/adapters/browser/adapter.ts @@ -0,0 +1,36 @@ +import type { IndexedDatabase } from "@valkyr/db"; + +import { Event } from "../../libraries/event.ts"; +import { EventStoreAdapter } from "../../types/adapter.ts"; +import { Adapter, Collections, EventStoreDB, getEventStoreDatabase } from "./database.ts"; +import { BrowserEventsProvider } from "./providers/events.ts"; +import { BrowserRelationsProvider } from "./providers/relations.ts"; +import { BrowserSnapshotsProvider } from "./providers/snapshots.ts"; + +/** + * A browser-based event store adapter that integrates database-specific providers. + * + * The `BrowserAdapter` enables event sourcing in a browser environment by utilizing + * IndexedDB for storage. It provides implementations for event storage, relations, + * and snapshots, allowing seamless integration with the shared event store interface. + * + * @template TEvent - The type of events managed by the event store. + */ +export class BrowserAdapter implements EventStoreAdapter { + readonly #database: IndexedDatabase; + + providers: EventStoreAdapter["providers"]; + + constructor(database: Adapter, name = "valkyr:event-store") { + this.#database = getEventStoreDatabase(name, database) as IndexedDatabase; + this.providers = { + events: new BrowserEventsProvider(this.#database.collection("events")), + relations: new BrowserRelationsProvider(this.#database.collection("relations")), + snapshots: new BrowserSnapshotsProvider(this.#database.collection("snapshots")), + }; + } + + get db(): IndexedDatabase { + return this.#database; + } +} diff --git a/adapters/browser/database.ts b/adapters/browser/database.ts new file mode 100644 index 0000000..d94cac7 --- /dev/null +++ b/adapters/browser/database.ts @@ -0,0 +1,73 @@ +import { IndexedDatabase, MemoryDatabase } from "@valkyr/db"; + +import { EventRecord } from "../../libraries/event.ts"; + +export function getEventStoreDatabase(name: string, adapter: Adapter): EventStoreDB { + switch (adapter) { + case "indexeddb": { + return new IndexedDatabase({ + name, + version: 1, + registrars: [ + { + name: "events", + indexes: [ + ["stream", { unique: false }], + ["created", { unique: false }], + ["recorded", { unique: false }], + ], + }, + { + name: "relations", + indexes: [ + ["key", { unique: false }], + ["stream", { unique: false }], + ], + }, + { + name: "snapshots", + indexes: [ + ["name", { unique: false }], + ["stream", { unique: false }], + ["cursor", { unique: false }], + ], + }, + ], + }); + } + case "memorydb": { + return new MemoryDatabase({ + name, + registrars: [{ name: "events" }, { name: "relations" }, { name: "snapshots" }], + }); + } + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type EventStoreDB = IndexedDatabase | MemoryDatabase; + +export type Adapter = "indexeddb" | "memorydb"; + +export type Collections = { + events: EventRecord; + relations: Relation; + snapshots: Snapshot; +}; + +export type Relation = { + key: string; + stream: string; +}; + +export type Snapshot = { + name: string; + stream: string; + cursor: string; + state: Record; +}; diff --git a/adapters/browser/providers/events.ts b/adapters/browser/providers/events.ts new file mode 100644 index 0000000..ffb456d --- /dev/null +++ b/adapters/browser/providers/events.ts @@ -0,0 +1,122 @@ +import type { Collection } from "@valkyr/db"; + +import { EventRecord } from "../../../libraries/event.ts"; +import type { EventsProvider } from "../../../types/adapter.ts"; +import type { EventReadOptions } from "../../../types/query.ts"; + +export class BrowserEventsProvider implements EventsProvider { + constructor(readonly events: Collection) {} + + /** + * Insert a new event record to the events table. + * + * @param record - Event record to insert. + * @param tx - Transaction to insert the record within. (Optional) + */ + async insert(record: EventRecord): Promise { + await this.events.insertOne(record); + } + + /** + * Insert many new event records to the events table. + * + * @param records - Event records to insert. + * @param batchSize - Batch size for the insert loop. + */ + async insertMany(records: EventRecord[], batchSize: number = 1_000): Promise { + for (let i = 0; i < records.length; i += batchSize) { + await this.events.insertMany(records.slice(i, i + batchSize)); + } + } + + /** + * Retrieve all the events in the events table. Optionally a cursor and direction + * can be provided to reduce the list of events returned. + * + * @param options - Find options. + */ + async get({ filter, cursor, direction }: EventReadOptions = {}): Promise { + const query: any = {}; + if (filter?.types !== undefined) { + withTypes(query, filter.types); + } + if (cursor !== undefined) { + withCursor(query, cursor, direction); + } + return (await this.events.find(query, { sort: { created: 1 } })) as EventRecord[]; + } + + /** + * Get events within the given stream. + * + * @param stream - Stream to fetch events for. + * @param options - Read options for modifying the result. + */ + async getByStream(stream: string, { filter, cursor, direction }: EventReadOptions = {}): Promise { + const query: any = { stream }; + if (filter?.types !== undefined) { + withTypes(query, filter.types); + } + if (cursor !== undefined) { + withCursor(query, cursor, direction); + } + return (await this.events.find(query, { sort: { created: 1 } })) as EventRecord[]; + } + + /** + * Get events within given list of streams. + * + * @param streams - Stream to get events for. + */ + async getByStreams(streams: string[], { filter, cursor, direction }: EventReadOptions = {}): Promise { + const query: any = { stream: { $in: streams } }; + if (filter?.types !== undefined) { + withTypes(query, filter.types); + } + if (cursor !== undefined) { + withCursor(query, cursor, direction ?? "asc"); + } + return (await this.events.find(query, { sort: { created: 1 } })) as EventRecord[]; + } + + /** + * Get a single event by its id. + * + * @param id - Event id. + */ + async getById(id: string): Promise { + return (await this.events.findById(id)) satisfies EventRecord | undefined; + } + + /** + * Check if the given event is outdated in relation to the local event data. + */ + async checkOutdated({ stream, type, created }: EventRecord): Promise { + const count = await this.events.count({ + stream, + type, + created: { + $gt: created, + }, + } as any); + return count > 0; + } +} + +/* + |-------------------------------------------------------------------------------- + | Query Builders + |-------------------------------------------------------------------------------- + */ + +function withTypes(filter: any, types: string[]): void { + filter.type = { $in: types }; +} + +function withCursor(filter: any, cursor: string, direction?: 1 | -1 | "asc" | "desc"): void { + if (cursor !== undefined) { + filter.created = { + [direction === "desc" || direction === -1 ? "$lt" : "$gt"]: cursor, + }; + } +} diff --git a/adapters/browser/providers/relations.ts b/adapters/browser/providers/relations.ts new file mode 100644 index 0000000..883320d --- /dev/null +++ b/adapters/browser/providers/relations.ts @@ -0,0 +1,109 @@ +import type { Collection } from "@valkyr/db"; + +import type { Relation, RelationPayload, RelationsProvider } from "../../../types/adapter.ts"; + +export class BrowserRelationsProvider implements RelationsProvider { + constructor(readonly relations: Collection) {} + + /** + * Handle incoming relation operations. + * + * @param relations - List of relation operations to execute. + */ + async handle(relations: Relation[]): Promise { + await Promise.all([ + this.insertMany(relations.filter((relation) => relation.op === "insert")), + this.removeMany(relations.filter((relation) => relation.op === "remove")), + ]); + } + + /** + * Add stream to the relations table. + * + * @param key - Relational key to add stream to. + * @param stream - Stream to add to the key. + */ + async insert(key: string, stream: string): Promise { + await this.relations.insertOne({ key, stream }); + } + + /** + * Add stream to many relational keys onto the relations table. + * + * @param relations - Relations to insert. + * @param batchSize - Batch size for the insert loop. + */ + async insertMany(relations: { key: string; stream: string }[], batchSize: number = 1_000): Promise { + for (let i = 0; i < relations.length; i += batchSize) { + await this.relations.insertMany(relations.slice(i, i + batchSize).map(({ key, stream }) => ({ key, stream }))); + } + } + + /** + * Get a list of event streams registered under the given relational key. + * + * @param key - Relational key to get event streams for. + */ + async getByKey(key: string): Promise { + return this.relations.find({ key }).then((relations) => relations.map(({ stream }) => stream)); + } + + /** + * Get a list of event streams registered under the given relational keys. + * + * @param keys - Relational keys to get event streams for. + */ + async getByKeys(keys: string[]): Promise { + return this.relations.find({ key: { $in: keys } }).then((relations) => { + const streamIds = new Set(); + for (const relation of relations) { + streamIds.add(relation.stream); + } + return Array.from(streamIds); + }); + } + + /** + * Removes a stream from the relational table. + * + * @param key - Relational key to remove stream from. + * @param stream - Stream to remove from relation. + */ + async remove(key: string, stream: string): Promise { + await this.relations.remove({ key, stream }); + } + + /** + * Removes multiple relational entries. + * + * @param relations - Relations to remove stream from. + * @param batchSize - Batch size for the insert loop. + */ + async removeMany(relations: RelationPayload[], batchSize: number = 1_000): Promise { + const promises = []; + for (let i = 0; i < relations.length; i += batchSize) { + for (const relation of relations.slice(i, i + batchSize)) { + promises.push(this.remove(relation.key, relation.stream)); + } + } + await Promise.all(promises); + } + + /** + * Remove all relations bound to the given relational keys. + * + * @param keys - Relational keys to remove from the relational table. + */ + async removeByKeys(keys: string[]): Promise { + await this.relations.remove({ key: { $in: keys } }); + } + + /** + * Remove all relations bound to the given streams. + * + * @param streams - Streams to remove from the relational table. + */ + async removeByStreams(streams: string[]): Promise { + await this.relations.remove({ stream: { $in: streams } }); + } +} diff --git a/adapters/browser/providers/snapshots.ts b/adapters/browser/providers/snapshots.ts new file mode 100644 index 0000000..48f6609 --- /dev/null +++ b/adapters/browser/providers/snapshots.ts @@ -0,0 +1,39 @@ +import type { Collection } from "@valkyr/db"; + +import type { Snapshot, SnapshotsProvider } from "../../../types/adapter.ts"; + +export class BrowserSnapshotsProvider implements SnapshotsProvider { + constructor(readonly snapshots: Collection) {} + + /** + * Add snapshot state under given reducer stream. + * + * @param name - Name of the reducer the snapshot is attached to. + * @param stream - Stream the snapshot is attached to. + * @param cursor - Cursor timestamp for the last event used in the snapshot. + * @param state - State of the reduced events. + */ + async insert(name: string, stream: string, cursor: string, state: Record): Promise { + await this.snapshots.insertOne({ name, stream, cursor, state }); + } + + /** + * Get snapshot state by stream. + * + * @param name - Name of the reducer which the state was created. + * @param stream - Stream the state was reduced for. + */ + async getByStream(name: string, stream: string): Promise { + return this.snapshots.findOne({ name, stream }); + } + + /** + * Removes a snapshot for the given reducer stream. + * + * @param name - Name of the reducer the snapshot is attached to. + * @param stream - Stream to remove from snapshots. + */ + async remove(name: string, stream: string): Promise { + await this.snapshots.remove({ name, stream }); + } +} diff --git a/adapters/mongo/adapter.ts b/adapters/mongo/adapter.ts new file mode 100644 index 0000000..80fa549 --- /dev/null +++ b/adapters/mongo/adapter.ts @@ -0,0 +1,91 @@ +import type { MongoConnectionUrl } from "@valkyr/testcontainers/mongodb"; +import { Db, MongoClient } from "mongodb"; + +import { EventStoreAdapter } from "../../types/adapter.ts"; +import { registrars } from "./collections/mod.ts"; +import { MongoEventsProvider } from "./providers/events.ts"; +import { MongoRelationsProvider } from "./providers/relations.ts"; +import { MongoSnapshotsProvider } from "./providers/snapshots.ts"; +import { DatabaseAccessor } from "./types.ts"; +import { getCollectionsSet } from "./utilities.ts"; + +/** + * A server-based event store adapter that integrates database-specific providers. + * + * The `MongoAdapter` enables event sourcing in a back end environment by utilizing + * MongoDB for storage. It provides implementations for event storage, relations, + * and snapshots, allowing seamless integration with the shared event store interface. + * + * @template TEvent - The type of events managed by the event store. + */ +export class MongoAdapter implements EventStoreAdapter { + readonly providers: { + readonly events: MongoEventsProvider; + readonly relations: MongoRelationsProvider; + readonly snapshots: MongoSnapshotsProvider; + }; + + readonly #accessor: DatabaseAccessor; + + constructor(connection: MongoConnection, db: string) { + this.#accessor = getDatabaseAccessor(connection, db); + this.providers = { + events: new MongoEventsProvider(this.#accessor), + relations: new MongoRelationsProvider(this.#accessor), + snapshots: new MongoSnapshotsProvider(this.#accessor), + }; + } + + get db(): DatabaseAccessor { + return this.#accessor; + } +} + +/** + * Takes a mongo database and registers the event store collections and + * indexes defined internally. + * + * @param db - Mongo database to register event store collections against. + * @param logger - Logger method to print internal logs. + */ +export async function register(db: Db, logger?: (...args: any[]) => any) { + const list = await getCollectionsSet(db); + for (const { name, indexes } of registrars) { + if (list.has(name)) { + continue; + } + await db.createCollection(name); + for (const [indexSpec, options] of indexes) { + await db.collection(name).createIndex(indexSpec, options); + logger?.("Mongo Event Store > Collection '%s' is indexed [%O] with options %O", name, indexSpec, options ?? {}); + } + logger?.("Mongo Event Store > Collection '%s' is registered", name); + } +} + +function getDatabaseAccessor(connection: MongoConnection, database: string): DatabaseAccessor { + let instance: Db | undefined; + return { + get db(): Db { + if (instance === undefined) { + instance = this.client.db(database); + } + return instance; + }, + get client(): MongoClient { + if (typeof connection === "string") { + return new MongoClient(connection); + } + if (connection instanceof MongoClient) { + return connection; + } + return connection(); + }, + }; +} + +/** + * Connection which the adapter supports, this can be a `url`, a `client` instance + * or a lazy method that provided `client` instance on demand. + */ +export type MongoConnection = MongoConnectionUrl | MongoClient | (() => MongoClient); diff --git a/adapters/mongo/collections/events.ts b/adapters/mongo/collections/events.ts new file mode 100644 index 0000000..98b6239 --- /dev/null +++ b/adapters/mongo/collections/events.ts @@ -0,0 +1,49 @@ +import z from "zod"; + +import type { CollectionRegistrar } from "../types.ts"; + +export const registrar: CollectionRegistrar = { + name: "events", + indexes: [ + [ + { + stream: 1, + }, + ], + [ + { + type: 1, + }, + ], + [ + { + recorded: 1, + }, + ], + [ + { + created: 1, + }, + ], + ], +}; + +export const schema = z.object({ + id: z.string(), + stream: z.string(), + type: z.string(), + data: z.any(), + meta: z.any(), + recorded: z.string(), + created: z.string(), +}); + +export type EventSchema = { + id: string; + stream: string; + type: string; + data: Record | null; + meta: Record | null; + recorded: string; + created: string; +}; diff --git a/adapters/mongo/collections/mod.ts b/adapters/mongo/collections/mod.ts new file mode 100644 index 0000000..8b96aa1 --- /dev/null +++ b/adapters/mongo/collections/mod.ts @@ -0,0 +1,10 @@ +import { CollectionRegistrar } from "../types.ts"; +import { registrar as events } from "./events.ts"; +import { registrar as relations } from "./relations.ts"; +import { registrar as snapshots } from "./snapshots.ts"; + +export const registrars: CollectionRegistrar[] = [ + events, + relations, + snapshots, +]; diff --git a/adapters/mongo/collections/relations.ts b/adapters/mongo/collections/relations.ts new file mode 100644 index 0000000..494eba7 --- /dev/null +++ b/adapters/mongo/collections/relations.ts @@ -0,0 +1,38 @@ +import z from "zod"; + +import type { CollectionRegistrar } from "../types.ts"; + +export const registrar: CollectionRegistrar = { + name: "relations", + indexes: [ + [ + { + key: 1, + }, + ], + [ + { + stream: 1, + }, + ], + [ + { + key: 1, + stream: 1, + }, + { + unique: true, + }, + ], + ], +}; + +export const schema = z.object({ + key: z.string(), + streams: z.string().array(), +}); + +export type RelationSchema = { + key: string; + streams: string[]; +}; diff --git a/adapters/mongo/collections/snapshots.ts b/adapters/mongo/collections/snapshots.ts new file mode 100644 index 0000000..55d12a8 --- /dev/null +++ b/adapters/mongo/collections/snapshots.ts @@ -0,0 +1,30 @@ +import z from "zod"; + +import type { CollectionRegistrar } from "../types.ts"; + +export const registrar: CollectionRegistrar = { + name: "snapshots", + indexes: [ + [ + { + name: 1, + stream: 1, + cursor: 1, + }, + ], + ], +}; + +export const schema = z.object({ + name: z.string(), + stream: z.string(), + cursor: z.string(), + state: z.record(z.string(), z.any()), +}); + +export type SnapshotSchema = { + name: string; + stream: string; + cursor: string; + state: Record; +}; diff --git a/adapters/mongo/providers/events.ts b/adapters/mongo/providers/events.ts new file mode 100644 index 0000000..ab9fed2 --- /dev/null +++ b/adapters/mongo/providers/events.ts @@ -0,0 +1,131 @@ +import type { Collection, FindCursor } from "mongodb"; + +import { EventRecord } from "../../../libraries/event.ts"; +import type { EventsProvider } from "../../../types/adapter.ts"; +import type { EventReadOptions } from "../../../types/query.ts"; +import { type EventSchema, schema } from "../collections/events.ts"; +import { DatabaseAccessor } from "../types.ts"; +import { toParsedRecord, toParsedRecords } from "../utilities.ts"; + +export class MongoEventsProvider implements EventsProvider { + readonly #accessor: DatabaseAccessor; + + constructor(accessor: DatabaseAccessor) { + this.#accessor = accessor; + } + + get collection(): Collection { + return this.#accessor.db.collection("events"); + } + + /** + * Insert a new event record to the events table. + * + * @param record - Event record to insert. + * @param tx - Transaction to insert the record within. (Optional) + */ + async insert(record: EventRecord): Promise { + await this.collection.insertOne(record, { forceServerObjectId: true }); + } + + /** + * Insert many new event records to the events table. + * + * @param records - Event records to insert. + */ + async insertMany(records: EventRecord[]): Promise { + await this.collection.insertMany(records, { forceServerObjectId: true }); + } + + /** + * Retrieve all the events in the events table. Optionally a cursor and direction + * can be provided to reduce the list of events returned. + * + * @param options - Find options. + */ + async get(options: EventReadOptions = {}): Promise { + return (await this.#withReadOptions(this.collection.find(this.#withFilters(options)), options) + .sort({ created: 1 }) + .toArray() + .then(toParsedRecords(schema))) as EventRecord[]; + } + + /** + * Get events within the given stream. + * + * @param stream - Stream to fetch events for. + * @param options - Read options for modifying the result. + */ + async getByStream(stream: string, options: EventReadOptions = {}): Promise { + return (await this.#withReadOptions(this.collection.find({ stream, ...this.#withFilters(options) }), options) + .sort({ created: 1 }) + .toArray() + .then(toParsedRecords(schema))) as EventRecord[]; + } + + /** + * Get events within given list of streams. + * + * @param streams - Stream to get events for. + * @param options - Read options for modifying the result. + */ + async getByStreams(streams: string[], options: EventReadOptions = {}): Promise { + return (await this.#withReadOptions(this.collection.find({ stream: { $in: streams }, ...this.#withFilters(options) }), options) + .sort({ created: 1 }) + .toArray() + .then(toParsedRecords(schema))) as EventRecord[]; + } + + /** + * Get a single event by its id. + * + * @param id - Event id. + */ + async getById(id: string): Promise { + return (await this.collection.findOne({ id }).then(toParsedRecord(schema))) as EventRecord | undefined; + } + + /** + * Check if the given event is outdated in relation to the local event data. + * + * @param event - Event record to check for outdated state for. + */ + async checkOutdated({ stream, type, created }: EventRecord): Promise { + const count = await this.collection.countDocuments({ + stream, + type, + created: { + $gt: created, + }, + }); + return count > 0; + } + + /* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + + #withFilters({ filter }: EventReadOptions): { type?: { $in: string[] } } { + const types = filter?.types; + if (types !== undefined) { + return { type: { $in: types } }; + } + return {}; + } + + #withReadOptions(fc: FindCursor, { cursor, direction, limit }: EventReadOptions): FindCursor { + if (cursor !== undefined) { + if (direction === "desc" || direction === -1) { + fc.filter({ created: { $lt: cursor } }); + } else { + fc.filter({ created: { $gt: cursor } }); + } + } + if (limit !== undefined) { + fc.limit(limit); + } + return fc; + } +} diff --git a/adapters/mongo/providers/relations.ts b/adapters/mongo/providers/relations.ts new file mode 100644 index 0000000..e069307 --- /dev/null +++ b/adapters/mongo/providers/relations.ts @@ -0,0 +1,168 @@ +import type { Collection } from "mongodb"; + +import { Relation, RelationPayload, RelationsProvider } from "../../../types/adapter.ts"; +import { type RelationSchema, schema } from "../collections/relations.ts"; +import { DatabaseAccessor } from "../types.ts"; +import { toParsedRecord, toParsedRecords } from "../utilities.ts"; + +export class MongoRelationsProvider implements RelationsProvider { + readonly #accessor: DatabaseAccessor; + + constructor(accessor: DatabaseAccessor) { + this.#accessor = accessor; + } + + get collection(): Collection { + return this.#accessor.db.collection("relations"); + } + + /** + * Handle incoming relation operations. + * + * @param relations - List of relation operations to execute. + */ + async handle(relations: Relation[]): Promise { + await Promise.all([ + this.insertMany(relations.filter((relation) => relation.op === "insert")), + this.removeMany(relations.filter((relation) => relation.op === "remove")), + ]); + } + + /** + * Add stream to the relations table. + * + * @param key - Relational key to add stream to. + * @param stream - Stream to add to the key. + */ + async insert(key: string, stream: string): Promise { + await this.collection.updateOne({ key }, { $addToSet: { streams: stream } }, { upsert: true }); + } + + /** + * Add stream to many relational keys onto the relations table. + * + * @param relations - Relations to insert. + */ + async insertMany(relations: RelationPayload[], batchSize = 1_000): Promise { + const reduced = relations.reduce((map, { key, stream }) => { + if (map.has(key) === false) { + map.set(key, new Set()); + } + map.get(key)!.add(stream); + return map; + }, new Map>()); + + const bulkOps = []; + for (const [key, streams] of reduced) { + bulkOps.push({ + updateOne: { + filter: { key }, + update: { $addToSet: { streams: { $each: Array.from(streams) } } }, + upsert: true, + }, + }); + } + + for (let i = 0; i < bulkOps.length; i += batchSize) { + await this.collection.bulkWrite(bulkOps.slice(i, i + batchSize), { ordered: false }); + } + } + + /** + * Get a list of event streams registered under the given relational key. + * + * @param key - Relational key to get event streams for. + */ + async getByKey(key: string): Promise { + const relations = await this.collection.findOne({ key }).then(toParsedRecord(schema)); + if (relations === undefined) { + return []; + } + return relations.streams; + } + + /** + * Get a list of event streams registered under the given relational keys. + * + * @param keys - Relational keys to get event streams for. + */ + async getByKeys(keys: string[]): Promise { + const streams = new Set(); + + const documents = await this.collection + .find({ key: { $in: keys } }) + .toArray() + .then(toParsedRecords(schema)); + documents.forEach((document) => { + for (const stream of document.streams) { + streams.add(stream); + } + }); + + return Array.from(streams); + } + + /** + * Removes a stream from the relational table. + * + * @param key - Relational key to remove stream from. + * @param stream - Stream to remove from relation. + */ + async remove(key: string, stream: string): Promise { + await this.collection.updateOne({ key }, { $pull: { streams: stream } }); + } + + /** + * Removes multiple relational entries. + * + * @param relations - Relations to remove stream from. + */ + async removeMany(relations: RelationPayload[], batchSize = 1_000): Promise { + const reduced = relations.reduce((map, { key, stream }) => { + if (!map.has(key)) { + map.set(key, new Set()); + } + map.get(key)!.add(stream); + return map; + }, new Map>()); + + const bulkOps = []; + for (const [key, streams] of reduced) { + bulkOps.push({ + updateOne: { + filter: { key }, + update: { $pull: { streams: { $in: Array.from(streams) } } }, + }, + }); + } + + for (let i = 0; i < bulkOps.length; i += batchSize) { + await this.collection.bulkWrite(bulkOps.slice(i, i + batchSize), { ordered: false }); + } + } + + /** + * Remove all relations bound to the given relational keys. + * + * @param keys - Relational keys to remove from the relational table. + */ + async removeByKeys(keys: string[]): Promise { + await this.collection.deleteMany({ key: { $in: keys } }); + } + + /** + * Remove all relations bound to the given streams. + * + * @param streams - Streams to remove from the relational table. + */ + async removeByStreams(streams: string[]): Promise { + await this.collection.bulkWrite( + streams.map((stream) => ({ + updateOne: { + filter: { streams: stream }, + update: { $pull: { streams: stream } }, + }, + })), + ); + } +} diff --git a/adapters/mongo/providers/snapshots.ts b/adapters/mongo/providers/snapshots.ts new file mode 100644 index 0000000..ba289f4 --- /dev/null +++ b/adapters/mongo/providers/snapshots.ts @@ -0,0 +1,50 @@ +import type { Collection } from "mongodb"; + +import { SnapshotsProvider } from "../../../types/adapter.ts"; +import { schema, type SnapshotSchema } from "../collections/snapshots.ts"; +import { DatabaseAccessor } from "../types.ts"; +import { toParsedRecord } from "../utilities.ts"; + +export class MongoSnapshotsProvider implements SnapshotsProvider { + readonly #accessor: DatabaseAccessor; + + constructor(accessor: DatabaseAccessor) { + this.#accessor = accessor; + } + + get collection(): Collection { + return this.#accessor.db.collection("snapshots"); + } + + /** + * Add snapshot state under given reducer stream. + * + * @param name - Name of the reducer the snapshot is attached to. + * @param stream - Stream the snapshot is attached to. + * @param cursor - Cursor timestamp for the last event used in the snapshot. + * @param state - State of the reduced events. + */ + async insert(name: string, stream: string, cursor: string, state: Record): Promise { + await this.collection.updateOne({ name }, { $set: { stream, cursor, state } }, { upsert: true }); + } + + /** + * Get snapshot state by stream. + * + * @param name - Name of the reducer which the state was created. + * @param stream - Stream the state was reduced for. + */ + async getByStream(name: string, stream: string): Promise { + return this.collection.findOne({ name, stream }).then(toParsedRecord(schema)); + } + + /** + * Removes a snapshot for the given reducer stream. + * + * @param name - Name of the reducer the snapshot is attached to. + * @param stream - Stream to remove from snapshots. + */ + async remove(name: string, stream: string): Promise { + await this.collection.deleteOne({ name, stream }); + } +} diff --git a/adapters/mongo/types.ts b/adapters/mongo/types.ts new file mode 100644 index 0000000..679e9e7 --- /dev/null +++ b/adapters/mongo/types.ts @@ -0,0 +1,11 @@ +import type { CreateIndexesOptions, Db, IndexSpecification, MongoClient } from "mongodb"; + +export type CollectionRegistrar = { + name: string; + indexes: [IndexSpecification, CreateIndexesOptions?][]; +}; + +export type DatabaseAccessor = { + db: Db; + client: MongoClient; +}; diff --git a/adapters/mongo/utilities.ts b/adapters/mongo/utilities.ts new file mode 100644 index 0000000..9a4835a --- /dev/null +++ b/adapters/mongo/utilities.ts @@ -0,0 +1,43 @@ +import type { Db, WithId } from "mongodb"; +import type { z, ZodObject } from "zod"; + +/** + * Take a list of records and run it through the given zod parser. This + * ensures that all the documents in resulting list adheres to the + * expected schematics before b eing returned. Any deviation in the list + * will result in an internal error being thrown. + * + * @param parser - Zod parser to run the documents through. + */ +export function toParsedRecords(parser: TSchema): (documents: WithId[]) => z.infer[] { + return parser.array().parse; +} + +/** + * Take a single nullable document value and run it through the given zod + * parser. This ensures that the data adheres to the expected schematics + * before being returned. Any deviation in the expected response will result + * in an internal error being thrown. + * + * @param parser - Zod parser to run the document through. + */ +export function toParsedRecord(parser: TSchema): (document: WithId | null) => z.infer | undefined { + return function (document) { + if (document === null) { + return undefined; + } + return parser.parse(document); + }; +} + +/** + * Get a Set of collections that exists on a given mongo database instance. + * + * @param db - Mongo database to fetch collection list for. + */ +export async function getCollectionsSet(db: Db) { + return db + .listCollections() + .toArray() + .then((collections) => new Set(collections.map((c) => c.name))); +} diff --git a/adapters/postgres/README.md b/adapters/postgres/README.md new file mode 100644 index 0000000..7190faa --- /dev/null +++ b/adapters/postgres/README.md @@ -0,0 +1,102 @@ +

+ +

+ +# Postgres Adapter + +The following instructions aims to guide you through setting up @valkyr/event-store with a postgres database. + +## Event Store + +Once we have defined our configs and printed our events we create a new postgres event store instance. + +```ts +import { makePostgresEventStore } from "@valkyr/event-store/postgres"; +import postgres from "postgres"; + +import { type Event, type EventRecord, events, validators } from "./generated/events.ts"; + +export const eventStore = makePostgresEventStore({ + connection: () => postgres("postgres://${string}:${string}@${string}:${number}/${string}"), // lazy loaded connection + schema: "event_store", + events, + validators, + hooks: { + async onError(error) { + // when the event store throws unhandled errors they will end up in + // this location that can be further logged in the systems own logger + // if onError hook is not provided all unhandled errors are logged + // through the `console.error` method. + }, + }, +}); + +const projector = new Projector(); + +eventStore.onEventsInserted(async (records, { batch }) => { + // trigger event side effects here such as sending the records through + // an event messaging system or other projection patterns + + // ### Projector + // The following is an example when registering event handlers with the + // projectors instance provided by this library. + + if (batch !== undefined) { + await projector.pushMany(batch, records); + } else { + for (const record of records) { + await projector.push(record, { hydrated: false, outdated: false }); + } + } +}); +``` + +## Migrations + +We do not manage migrations in your local solutions so what we provide is a sample SQL script for optimal query setup. The following example assumes all event tables goes into a `event_store` schema. If you are adding these tables to a different schema or into the public default postgres space you will need to modify this sample accordingly. + +```sql +CREATE SCHEMA "event_store"; + +-- Event Table + +CREATE TABLE IF NOT EXISTS "event_store"."events" ( + "id" varchar PRIMARY KEY NOT NULL, + "stream" varchar NOT NULL, + "type" varchar NOT NULL, + "data" jsonb NOT NULL, + "meta" jsonb NOT NULL, + "recorded" varchar NOT NULL, + "created" varchar NOT NULL +); + +CREATE INDEX IF NOT EXISTS "events_stream_index" ON "event_store"."events" USING btree ("stream"); +CREATE INDEX IF NOT EXISTS "events_type_index" ON "event_store"."events" USING btree ("type"); +CREATE INDEX IF NOT EXISTS "events_recorded_index" ON "event_store"."events" USING btree ("recorded"); +CREATE INDEX IF NOT EXISTS "events_created_index" ON "event_store"."events" USING btree ("created"); + +-- Relations Table + +CREATE TABLE IF NOT EXISTS "event_store"."relations" ( + "id" serial PRIMARY KEY NOT NULL, + "key" varchar NOT NULL, + "stream" varchar NOT NULL, + UNIQUE ("key", "stream") +); + +CREATE INDEX IF NOT EXISTS "relations_key_index" ON "event_store"."relations" USING btree ("key"); +CREATE INDEX IF NOT EXISTS "relations_stream_index" ON "event_store"."relations" USING btree ("stream"); + +-- Snapshots Table + +CREATE TABLE IF NOT EXISTS "event_store"."snapshots" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "stream" varchar NOT NULL, + "cursor" varchar NOT NULL, + "state" jsonb NOT NULL, + UNIQUE ("name", "stream") +); + +CREATE INDEX IF NOT EXISTS "snapshots_name_stream_cursor_index" ON "event_store"."snapshots" USING btree ("name","stream","cursor"); +``` diff --git a/adapters/postgres/adapter.ts b/adapters/postgres/adapter.ts new file mode 100644 index 0000000..2dcfdc3 --- /dev/null +++ b/adapters/postgres/adapter.ts @@ -0,0 +1,45 @@ +import { EventStoreAdapter } from "../../types/adapter.ts"; +import { PostgresConnection } from "./connection.ts"; +import { PostgresDatabase } from "./database.ts"; +import { PostgresEventsProvider } from "./providers/event.ts"; +import { PostgresRelationsProvider } from "./providers/relations.ts"; +import { PostgresSnapshotsProvider } from "./providers/snapshot.ts"; + +/** + * A server-based event store adapter that integrates database-specific providers. + * + * The `PostgresAdapter` enables event sourcing in a back end environment by utilizing + * PostgreSql for storage. It provides implementations for event storage, relations, + * and snapshots, allowing seamless integration with the shared event store interface. + * + * @template TEvent - The type of events managed by the event store. + */ +export class PostgresAdapter implements EventStoreAdapter { + readonly providers: { + readonly events: PostgresEventsProvider; + readonly relations: PostgresRelationsProvider; + readonly snapshots: PostgresSnapshotsProvider; + }; + + #database: PostgresDatabase; + + constructor( + readonly connection: PostgresConnection, + readonly options: Options = {}, + ) { + this.#database = new PostgresDatabase(connection); + this.providers = { + events: new PostgresEventsProvider(this.#database, options.schema), + relations: new PostgresRelationsProvider(this.#database, options.schema), + snapshots: new PostgresSnapshotsProvider(this.#database, options.schema), + }; + } + + get db(): PostgresDatabase { + return this.#database; + } +} + +type Options = { + schema?: string; +}; diff --git a/adapters/postgres/connection.ts b/adapters/postgres/connection.ts new file mode 100644 index 0000000..16de9af --- /dev/null +++ b/adapters/postgres/connection.ts @@ -0,0 +1,7 @@ +import type { Options, Sql } from "postgres"; + +export type PostgresConnection = [PostgresConnectionUrl, Options?] | [Options] | Sql | PostgresConnectionFactory; + +type PostgresConnectionUrl = `postgres://${string}:${string}@${string}:${number}/${string}`; + +type PostgresConnectionFactory = () => Sql; diff --git a/adapters/postgres/database.ts b/adapters/postgres/database.ts new file mode 100644 index 0000000..b3dd20d --- /dev/null +++ b/adapters/postgres/database.ts @@ -0,0 +1,36 @@ +import postgres, { type Sql } from "postgres"; + +import { PostgresConnection } from "./connection.ts"; + +export class PostgresDatabase { + readonly #connection: PostgresConnection; + + #sql?: Sql; + + constructor(connection: PostgresConnection) { + this.#connection = connection; + } + + get sql(): Sql { + if (this.#sql === undefined) { + const connection = this.#connection; + if (Array.isArray(connection)) { + const [urlOrOptions, option] = connection; + if (typeof urlOrOptions === "string") { + this.#sql = postgres(urlOrOptions, option); + } else { + this.#sql = postgres(urlOrOptions); + } + } else if ("options" in connection) { + this.#sql = connection; + } else { + this.#sql = connection(); + } + } + return this.#sql; + } +} + +export type DatabaseAccessor = { + sql: Sql; +}; diff --git a/adapters/postgres/providers/event.ts b/adapters/postgres/providers/event.ts new file mode 100644 index 0000000..29a0693 --- /dev/null +++ b/adapters/postgres/providers/event.ts @@ -0,0 +1,175 @@ +import type { Helper } from "postgres"; + +import type { EventRecord } from "../../../libraries/event.ts"; +import type { EventsProvider } from "../../../types/adapter.ts"; +import type { EventReadOptions } from "../../../types/query.ts"; +import type { PostgresDatabase } from "../database.ts"; + +type PGEventRecord = Omit & { data: string; meta: string }; + +export class PostgresEventsProvider implements EventsProvider { + constructor( + readonly db: PostgresDatabase, + readonly schema?: string, + ) {} + + get table(): Helper { + if (this.schema !== undefined) { + return this.db.sql(`${this.schema}.events`); + } + return this.db.sql("public.events"); + } + + /** + * Insert a new event record to the events table. + * + * @param record - Event record to insert. + */ + async insert(record: EventRecord): Promise { + await this.db.sql`INSERT INTO ${this.table} ${this.db.sql(this.#toDriver(record))}`.catch((error) => { + throw new Error(`EventStore > 'events.insert' failed with postgres error: ${error.message}`); + }); + } + + /** + * Insert many new event records to the events table. + * + * @param records - Event records to insert. + * @param batchSize - Batch size for the insert loop. + */ + async insertMany(records: EventRecord[], batchSize: number = 1_000): Promise { + await this.db.sql + .begin(async (sql) => { + for (let i = 0; i < records.length; i += batchSize) { + await sql`INSERT INTO ${this.table} ${this.db.sql(records.slice(i, i + batchSize).map(this.#toDriver))}`; + } + }) + .catch((error) => { + throw new Error(`EventStore > 'events.insertMany' failed with postgres error: ${error.message}`); + }); + } + + /** + * Retrieve all the events in the events table. Optionally a cursor and direction + * can be provided to reduce the list of events returned. + * + * @param options - Find options. + */ + async get(options: EventReadOptions): Promise { + if (options !== undefined) { + const { filter, cursor, direction, limit } = options; + return this.db.sql` + SELECT * FROM ${this.table} + WHERE + ${filter?.types ? this.#withTypes(filter.types) : this.db.sql``} + ${cursor ? this.#withCursor(cursor, direction) : this.db.sql``} + ORDER BY created ASC + ${limit ? this.#withLimit(limit) : this.db.sql``} + `.then(this.#fromDriver); + } + return this.db.sql`SELECT * FROM ${this.table} ORDER BY created ASC`.then(this.#fromDriver); + } + + /** + * Get events within the given stream. + * + * @param stream - Stream to fetch events for. + * @param options - Read options for modifying the result. + */ + async getByStream(stream: string, { filter, cursor, direction, limit }: EventReadOptions = {}): Promise { + return this.db.sql` + SELECT * FROM ${this.table} + WHERE + stream = ${stream} + ${filter?.types ? this.#withTypes(filter.types) : this.db.sql``} + ${cursor ? this.#withCursor(cursor, direction) : this.db.sql``} + ORDER BY created ASC + ${limit ? this.#withLimit(limit) : this.db.sql``} + `.then(this.#fromDriver); + } + + /** + * Get events within given list of streams. + * + * @param streams - Stream to get events for. + * @param options - Read options for modifying the result. + */ + async getByStreams(streams: string[], { filter, cursor, direction, limit }: EventReadOptions = {}): Promise { + return this.db.sql` + SELECT * FROM ${this.table} + WHERE + stream IN ${this.db.sql(streams)} + ${filter?.types ? this.#withTypes(filter.types) : this.db.sql``} + ${cursor ? this.#withCursor(cursor, direction) : this.db.sql``} + ORDER BY created ASC + ${limit ? this.#withLimit(limit) : this.db.sql``} + `.then(this.#fromDriver); + } + + /** + * Get a single event by its id. + * + * @param id - Event id. + */ + async getById(id: string): Promise { + return this.db.sql`SELECT * FROM ${this.table} WHERE id = ${id}`.then(this.#fromDriver).then(([record]) => record); + } + + /** + * Check if the given event is outdated in relation to the local event data. + */ + async checkOutdated({ stream, type, created }: EventRecord): Promise { + const count = await await this.db.sql` + SELECT COUNT(*) AS count + FROM ${this.table} + WHERE + stream = ${stream} + AND type = ${type} + AND created > ${created} + `.then((result: any) => Number(result[0])); + return count > 0; + } + + /* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + + #withTypes(types: string[]) { + return this.db.sql`AND type IN ${this.db.sql(types)}`; + } + + #withCursor(cursor: string, direction?: 1 | -1 | "asc" | "desc") { + if (direction === "desc" || direction === -1) { + return this.db.sql`AND created < ${cursor}`; + } + return this.db.sql`AND created > ${cursor}`; + } + + #withLimit(limit: number) { + return this.db.sql`LIMIT ${limit}`; + } + + /* + |-------------------------------------------------------------------------------- + | Parsers + |-------------------------------------------------------------------------------- + */ + + #fromDriver(records: PGEventRecord[]): EventRecord[] { + return records.map((record) => { + record.data = typeof record.data === "string" ? JSON.parse(record.data) : record.data; + record.meta = typeof record.meta === "string" ? JSON.parse(record.meta) : record.meta; + return record as unknown as EventRecord; + }); + } + + #toDriver(record: EventRecord): PGEventRecord { + return { + ...record, + data: JSON.stringify(record.data), + meta: JSON.stringify(record.meta), + }; + } +} diff --git a/adapters/postgres/providers/relations.ts b/adapters/postgres/providers/relations.ts new file mode 100644 index 0000000..f85c1f4 --- /dev/null +++ b/adapters/postgres/providers/relations.ts @@ -0,0 +1,140 @@ +import type { Helper } from "postgres"; + +import type { Relation, RelationPayload, RelationsProvider } from "../../../types/adapter.ts"; +import type { PostgresDatabase } from "../database.ts"; + +export class PostgresRelationsProvider implements RelationsProvider { + constructor( + readonly db: PostgresDatabase, + readonly schema?: string, + ) {} + + get table(): Helper { + if (this.schema !== undefined) { + return this.db.sql(`${this.schema}.relations`); + } + return this.db.sql("public.relations"); + } + + /** + * Handle incoming relation operations. + * + * @param relations - List of relation operations to execute. + */ + async handle(relations: Relation[]): Promise { + await Promise.all([ + this.insertMany(relations.filter((relation) => relation.op === "insert")), + this.removeMany(relations.filter((relation) => relation.op === "remove")), + ]); + } + + /** + * Add stream to the relations table. + * + * @param key - Relational key to add stream to. + * @param stream - Stream to add to the key. + */ + async insert(key: string, stream: string): Promise { + await this.db.sql`INSERT INTO ${this.table} (key, stream) VALUES (${key}, ${stream}) ON CONFLICT DO NOTHING`.catch((error) => { + throw new Error(`EventStore > 'relations.insert' failed with postgres error: ${error.message}`); + }); + } + + /** + * Add stream to many relational keys onto the relations table. + * + * @param relations - Relations to insert. + * @param batchSize - Batch size for the insert loop. + */ + async insertMany(relations: RelationPayload[], batchSize: number = 1_000): Promise { + await this.db.sql + .begin(async (sql) => { + for (let i = 0; i < relations.length; i += batchSize) { + const values = relations.slice(i, i + batchSize).map(({ key, stream }) => [key, stream]); + await sql`INSERT INTO ${this.table} (key, stream) VALUES ${sql(values)} ON CONFLICT DO NOTHING`; + } + }) + .catch((error) => { + throw new Error(`EventStore > 'relations.insertMany' failed with postgres error: ${error.message}`); + }); + } + + /** + * Get a list of event streams registered under the given relational key. + * + * @param key - Relational key to get event streams for. + */ + async getByKey(key: string): Promise { + return this.db.sql`SELECT stream FROM ${this.table} WHERE key = ${key}` + .then((rows) => rows.map(({ stream }) => stream)) + .catch((error) => { + throw new Error(`EventStore > 'relations.getByKey' failed with postgres error: ${error.message}`); + }); + } + + /** + * Get a list of event streams registered under the given relational keys. + * + * @param keys - Relational keys to get event streams for. + */ + async getByKeys(keys: string[]): Promise { + return this.db.sql`SELECT DISTINCT stream FROM ${this.table} WHERE key IN ${this.db.sql(keys)}` + .then((rows) => rows.map(({ stream }) => stream)) + .catch((error) => { + throw new Error(`EventStore > 'relations.getByKeys' failed with postgres error: ${error.message}`); + }); + } + + /** + * Removes a stream from the relational table. + * + * @param key - Relational key to remove stream from. + * @param stream - Stream to remove from relation. + */ + async remove(key: string, stream: string): Promise { + await this.db.sql`DELETE FROM ${this.table} WHERE key = ${key} AND stream = ${stream}`.catch((error) => { + throw new Error(`EventStore > 'relations.remove' failed with postgres error: ${error.message}`); + }); + } + + /** + * Removes multiple relational entries. + * + * @param relations - Relations to remove stream from. + * @param batchSize - Batch size for the insert loop. + */ + async removeMany(relations: RelationPayload[], batchSize: number = 1_000): Promise { + await this.db.sql + .begin(async (sql) => { + for (let i = 0; i < relations.length; i += batchSize) { + const conditions = relations.slice(i, i + batchSize).map(({ key, stream }) => `(key = '${key}' AND stream = '${stream}')`); + await sql`DELETE FROM ${this.table} WHERE ${this.db.sql.unsafe(conditions.join(" OR "))}`; + } + }) + .catch((error) => { + throw new Error(`EventStore > 'relations.removeMany' failed with postgres error: ${error.message}`); + }); + } + + /** + * Remove all relations bound to the given relational keys. + * + * @param keys - Relational keys to remove from the relational table. + */ + async removeByKeys(keys: string[]): Promise { + await this.db.sql`DELETE FROM ${this.table} WHERE key IN ${this.db.sql(keys)}`.catch((error) => { + throw new Error(`EventStore > 'relations.removeByKeys' failed with postgres error: ${error.message}`); + }); + } + + /** + * Remove all relations bound to the given streams. + * + * @param streams - Streams to remove from the relational table. + */ + async removeByStreams(streams: string[]): Promise { + await this.db.sql`DELETE FROM ${this.table} WHERE stream IN ${this.db.sql(streams)}`.catch((error) => { + throw new Error(`EventStore > 'relations.removeByStreams' failed with postgres error: ${error.message}`); + }); + } +} diff --git a/adapters/postgres/providers/snapshot.ts b/adapters/postgres/providers/snapshot.ts new file mode 100644 index 0000000..8887294 --- /dev/null +++ b/adapters/postgres/providers/snapshot.ts @@ -0,0 +1,82 @@ +import type { Helper } from "postgres"; + +import type { Snapshot, SnapshotsProvider } from "../../../types/adapter.ts"; +import type { PostgresDatabase } from "../database.ts"; + +type PGSnapshot = Omit & { state: string }; + +export class PostgresSnapshotsProvider implements SnapshotsProvider { + constructor( + readonly db: PostgresDatabase, + readonly schema?: string, + ) {} + + get table(): Helper { + if (this.schema !== undefined) { + return this.db.sql(`${this.schema}.snapshots`); + } + return this.db.sql("public.snapshots"); + } + + /** + * Add snapshot state under given reducer stream. + * + * @param name - Name of the reducer the snapshot is attached to. + * @param stream - Stream the snapshot is attached to. + * @param cursor - Cursor timestamp for the last event used in the snapshot. + * @param state - State of the reduced events. + */ + async insert(name: string, stream: string, cursor: string, state: any): Promise { + await this.db.sql` + INSERT INTO ${this.table} ${this.db.sql(this.#toDriver({ name, stream, cursor, state }))}`.catch((error) => { + throw new Error(`EventStore > 'snapshots.insert' failed with postgres error: ${error.message}`); + }); + } + + /** + * Get snapshot state by stream. + * + * @param name - Name of the reducer which the state was created. + * @param stream - Stream the state was reduced for. + */ + async getByStream(name: string, stream: string): Promise { + return this.db.sql`SELECT * FROM ${this.table} WHERE name = ${name} AND stream = ${stream}` + .then(this.#fromDriver) + .then(([snapshot]) => snapshot) + .catch((error) => { + throw new Error(`EventStore > 'snapshots.getByStream' failed with postgres error: ${error.message}`); + }); + } + + /** + * Removes a snapshot for the given reducer stream. + * + * @param name - Name of the reducer the snapshot is attached to. + * @param stream - Stream to remove from snapshots. + */ + async remove(name: string, stream: string): Promise { + await this.db.sql`DELETE FROM ${this.table} WHERE name = ${name} AND stream = ${stream}`.catch((error) => { + throw new Error(`EventStore > 'snapshots.remove' failed with postgres error: ${error.message}`); + }); + } + + /* + |-------------------------------------------------------------------------------- + | Parsers + |-------------------------------------------------------------------------------- + */ + + #fromDriver(snapshots: PGSnapshot[]): Snapshot[] { + return snapshots.map((snapshot) => { + snapshot.state = typeof snapshot.state === "string" ? JSON.parse(snapshot.state) : snapshot.state; + return snapshot as unknown as Snapshot; + }); + } + + #toDriver(snapshot: Snapshot): object { + return { + ...snapshot, + state: JSON.stringify(snapshot.state), + }; + } +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..5db79d5 --- /dev/null +++ b/deno.json @@ -0,0 +1,27 @@ +{ + "name": "@valkyr/event-store", + "version": "2.0.0", + "exports": { + ".": "./mod.ts", + "./browser": "./adapters/browser/adapter.ts", + "./mongo": "./adapters/mongo/adapter.ts", + "./postgres": "./adapters/postgres/adapter.ts", + "./printer": "./printers/printer.ts" + }, + "publish": { + "exclude": [ + ".github", + ".vscode", + ".gitignore", + "tests" + ] + }, + "tasks": { + "check": "deno check ./mod.ts", + "lint": "npx eslint -c eslint.config.mjs .", + "test": "deno test --allow-all", + "test:publish": "deno publish --dry-run", + "ncu": "npx ncu -u -p npm" + }, + "nodeModulesDir": "auto" +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..961e290 --- /dev/null +++ b/deno.lock @@ -0,0 +1,871 @@ +{ + "version": "4", + "specifiers": { + "npm:@jsr/std__assert@1.0.12": "1.0.12", + "npm:@jsr/std__async@1.0.12": "1.0.12", + "npm:@jsr/std__testing@1.0.11": "1.0.11", + "npm:@jsr/valkyr__testcontainers@2.0.0": "2.0.0", + "npm:@valkyr/db@1.0.1": "1.0.1", + "npm:eslint-plugin-simple-import-sort@12.1.1": "12.1.1_eslint@9.24.0", + "npm:eslint@9.24.0": "9.24.0", + "npm:fake-indexeddb@6.0.0": "6.0.0", + "npm:mongodb@6.15.0": "6.15.0", + "npm:nanoid@5.1.5": "5.1.5", + "npm:postgres@3.4.5": "3.4.5", + "npm:postgres@^3.4.5": "3.4.5", + "npm:prettier@3.5.3": "3.5.3", + "npm:typescript-eslint@8.30.1": "8.30.1_eslint@9.24.0_typescript@5.8.3_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3", + "npm:zod@next": "4.0.0-beta.20250420T053007" + }, + "npm": { + "@eslint-community/eslint-utils@4.6.1_eslint@9.24.0": { + "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==", + "dependencies": [ + "eslint", + "eslint-visitor-keys@3.4.3" + ] + }, + "@eslint-community/regexpp@4.12.1": { + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==" + }, + "@eslint/config-array@0.20.0": { + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dependencies": [ + "@eslint/object-schema", + "debug", + "minimatch@3.1.2" + ] + }, + "@eslint/config-helpers@0.2.1": { + "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==" + }, + "@eslint/core@0.12.0": { + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "dependencies": [ + "@types/json-schema" + ] + }, + "@eslint/core@0.13.0": { + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "dependencies": [ + "@types/json-schema" + ] + }, + "@eslint/eslintrc@3.3.1": { + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dependencies": [ + "ajv", + "debug", + "espree", + "globals", + "ignore", + "import-fresh", + "js-yaml", + "minimatch@3.1.2", + "strip-json-comments" + ] + }, + "@eslint/js@9.24.0": { + "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==" + }, + "@eslint/object-schema@2.1.6": { + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==" + }, + "@eslint/plugin-kit@0.2.8": { + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "dependencies": [ + "@eslint/core@0.13.0", + "levn" + ] + }, + "@humanfs/core@0.19.1": { + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==" + }, + "@humanfs/node@0.16.6": { + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dependencies": [ + "@humanfs/core", + "@humanwhocodes/retry@0.3.1" + ] + }, + "@humanwhocodes/module-importer@1.0.1": { + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" + }, + "@humanwhocodes/retry@0.3.1": { + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==" + }, + "@humanwhocodes/retry@0.4.2": { + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==" + }, + "@jsr/std__assert@1.0.12": { + "integrity": "sha512-9pmgjJhuljZCmLlbvsRV6aLT5+YCmhX/yIjaWYav7R7Vup2DOLAgpUOs4JkzRbwn7fdKYrwHT8+DjqPr7Ti8mg==", + "dependencies": [ + "@jsr/std__internal" + ] + }, + "@jsr/std__async@1.0.12": { + "integrity": "sha512-NUaSOcwMetVeVkIqet2Ammy2A5YxG8ViFxryBbTaC4h7l/cgAkU59U3zF58ek4Y8HZ0Nx5De7qBptPfp62kcgw==" + }, + "@jsr/std__data-structures@1.0.6": { + "integrity": "sha512-Ejc8mHLuoYxXLu2zPquvqijdgQ19OV+1DdVDrLc/Cg+tiuGh4Dq2FSnLiPINh4lO1AJ3XcZcYPx38RxdsZcCOg==" + }, + "@jsr/std__fs@1.0.16": { + "integrity": "sha512-xnqp8XqEFN+ttkERg9GG+AxyipSd+rfCquLPviF5ZSwN6oCV1TM0ZNoKHXNk/EJAsz28YjF4sfgdJt8XwTV2UQ==", + "dependencies": [ + "@jsr/std__path" + ] + }, + "@jsr/std__internal@1.0.6": { + "integrity": "sha512-1NLtCx9XAL44nt56gzmRSCgXjIthHVzK62fTkJdq8/XsP7eN9a21AZDpc0EGJ/cgvmmOB52UGh46OuKrrY7eVg==" + }, + "@jsr/std__net@1.0.4": { + "integrity": "sha512-KJGU8ZpQ70sMW2Zk+wU3wFUkggS9lTLfRFBygnV9VaK8KI+1ggiqtB06rH4a14CNRGM9y46Mn/ZCbQUd4Q45Jg==" + }, + "@jsr/std__path@1.0.8": { + "integrity": "sha512-eNBGlh/8ZVkMxtFH4bwIzlAeKoHYk5in4wrBZhi20zMdOiuX4QozP4+19mIXBT2lzHDjhuVLyECbhFeR304iDg==" + }, + "@jsr/std__testing@1.0.11": { + "integrity": "sha512-pqQDYtIsaDf+x4NHQ+WiixRJ8DfhgFQRdlHWWssFAzIYwleR+VHLTNlgsgg+AH3mIIR+gTkBmKk21hTkM/WbMQ==", + "dependencies": [ + "@jsr/std__assert", + "@jsr/std__async", + "@jsr/std__data-structures", + "@jsr/std__fs", + "@jsr/std__internal", + "@jsr/std__path" + ] + }, + "@jsr/valkyr__testcontainers@2.0.0": { + "integrity": "sha512-aK78hRoVyQm3M0aFucuUV7Ghfx4295fJ6Q3fSjtJizYnu10VuktKfcIh5xHhOVAISk1Zh0y3SYGIiuZiKr57vw==", + "dependencies": [ + "@jsr/std__async", + "@jsr/std__fs", + "@jsr/std__net", + "mongodb", + "postgres" + ] + }, + "@mongodb-js/saslprep@1.2.2": { + "integrity": "sha512-EB0O3SCSNRUFk66iRCpI+cXzIjdswfCs7F6nOC3RAGJ7xr5YhaicvsRwJ9eyzYvYRlCSDUO/c7g4yNulxKC1WA==", + "dependencies": [ + "sparse-bitfield" + ] + }, + "@nodelib/fs.scandir@2.1.5": { + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": [ + "@nodelib/fs.stat", + "run-parallel" + ] + }, + "@nodelib/fs.stat@2.0.5": { + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk@1.2.8": { + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": [ + "@nodelib/fs.scandir", + "fastq" + ] + }, + "@types/estree@1.0.7": { + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" + }, + "@types/json-schema@7.0.15": { + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, + "@types/webidl-conversions@7.0.3": { + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "@types/whatwg-url@11.0.5": { + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": [ + "@types/webidl-conversions" + ] + }, + "@typescript-eslint/eslint-plugin@8.30.1_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3_eslint@9.24.0_typescript@5.8.3": { + "integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", + "dependencies": [ + "@eslint-community/regexpp", + "@typescript-eslint/parser", + "@typescript-eslint/scope-manager", + "@typescript-eslint/type-utils", + "@typescript-eslint/utils", + "@typescript-eslint/visitor-keys", + "eslint", + "graphemer", + "ignore", + "natural-compare", + "ts-api-utils", + "typescript" + ] + }, + "@typescript-eslint/parser@8.30.1_eslint@9.24.0_typescript@5.8.3": { + "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", + "dependencies": [ + "@typescript-eslint/scope-manager", + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "@typescript-eslint/visitor-keys", + "debug", + "eslint", + "typescript" + ] + }, + "@typescript-eslint/scope-manager@8.30.1": { + "integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", + "dependencies": [ + "@typescript-eslint/types", + "@typescript-eslint/visitor-keys" + ] + }, + "@typescript-eslint/type-utils@8.30.1_eslint@9.24.0_typescript@5.8.3": { + "integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", + "dependencies": [ + "@typescript-eslint/typescript-estree", + "@typescript-eslint/utils", + "debug", + "eslint", + "ts-api-utils", + "typescript" + ] + }, + "@typescript-eslint/types@8.30.1": { + "integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==" + }, + "@typescript-eslint/typescript-estree@8.30.1_typescript@5.8.3": { + "integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", + "dependencies": [ + "@typescript-eslint/types", + "@typescript-eslint/visitor-keys", + "debug", + "fast-glob", + "is-glob", + "minimatch@9.0.5", + "semver", + "ts-api-utils", + "typescript" + ] + }, + "@typescript-eslint/utils@8.30.1_eslint@9.24.0_typescript@5.8.3": { + "integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", + "dependencies": [ + "@eslint-community/eslint-utils", + "@typescript-eslint/scope-manager", + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "eslint", + "typescript" + ] + }, + "@typescript-eslint/visitor-keys@8.30.1": { + "integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", + "dependencies": [ + "@typescript-eslint/types", + "eslint-visitor-keys@4.2.0" + ] + }, + "@valkyr/db@1.0.1": { + "integrity": "sha512-zOvf0jbTSOtjzAgWKeD6S3/QQdtodPy+LkxfnhoggOzYhthkmZ1A8SauucFgkvIrzEp8e3IfNBHy0qQUHJRTog==", + "dependencies": [ + "dot-prop", + "fast-equals", + "idb", + "mingo", + "nanoid@5.0.2", + "rfdc", + "rxjs" + ] + }, + "@zod/core@0.8.1": { + "integrity": "sha512-djj8hPhxIHcG8ptxITaw/Bout5HJZ9NyRbKr95Eilqwt9R0kvITwUQGDU+n+MVdsBIka5KwztmZSLti22F+P0A==" + }, + "acorn-jsx@5.3.2_acorn@8.14.1": { + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dependencies": [ + "acorn" + ] + }, + "acorn@8.14.1": { + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==" + }, + "ajv@6.12.6": { + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": [ + "fast-deep-equal", + "fast-json-stable-stringify", + "json-schema-traverse", + "uri-js" + ] + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "argparse@2.0.1": { + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "balanced-match@1.0.2": { + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "brace-expansion@1.1.11": { + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": [ + "balanced-match", + "concat-map" + ] + }, + "brace-expansion@2.0.1": { + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": [ + "balanced-match" + ] + }, + "braces@3.0.3": { + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": [ + "fill-range" + ] + }, + "bson@6.10.3": { + "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==" + }, + "callsites@3.1.0": { + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" + }, + "chalk@4.1.2": { + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": [ + "ansi-styles", + "supports-color" + ] + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "concat-map@0.0.1": { + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "cross-spawn@7.0.6": { + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": [ + "path-key", + "shebang-command", + "which" + ] + }, + "debug@4.4.0": { + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dependencies": [ + "ms" + ] + }, + "deep-is@0.1.4": { + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + }, + "dot-prop@8.0.2": { + "integrity": "sha512-xaBe6ZT4DHPkg0k4Ytbvn5xoxgpG0jOS1dYxSOwAHPuNLjP3/OzN0gH55SrLqpx8cBfSaVt91lXYkApjb+nYdQ==", + "dependencies": [ + "type-fest" + ] + }, + "escape-string-regexp@4.0.0": { + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "eslint-plugin-simple-import-sort@12.1.1_eslint@9.24.0": { + "integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==", + "dependencies": [ + "eslint" + ] + }, + "eslint-scope@8.3.0": { + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "dependencies": [ + "esrecurse", + "estraverse" + ] + }, + "eslint-visitor-keys@3.4.3": { + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" + }, + "eslint-visitor-keys@4.2.0": { + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==" + }, + "eslint@9.24.0": { + "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "dependencies": [ + "@eslint-community/eslint-utils", + "@eslint-community/regexpp", + "@eslint/config-array", + "@eslint/config-helpers", + "@eslint/core@0.12.0", + "@eslint/eslintrc", + "@eslint/js", + "@eslint/plugin-kit", + "@humanfs/node", + "@humanwhocodes/module-importer", + "@humanwhocodes/retry@0.4.2", + "@types/estree", + "@types/json-schema", + "ajv", + "chalk", + "cross-spawn", + "debug", + "escape-string-regexp", + "eslint-scope", + "eslint-visitor-keys@4.2.0", + "espree", + "esquery", + "esutils", + "fast-deep-equal", + "file-entry-cache", + "find-up", + "glob-parent@6.0.2", + "ignore", + "imurmurhash", + "is-glob", + "json-stable-stringify-without-jsonify", + "lodash.merge", + "minimatch@3.1.2", + "natural-compare", + "optionator" + ] + }, + "espree@10.3.0_acorn@8.14.1": { + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dependencies": [ + "acorn", + "acorn-jsx", + "eslint-visitor-keys@4.2.0" + ] + }, + "esquery@1.6.0": { + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dependencies": [ + "estraverse" + ] + }, + "esrecurse@4.3.0": { + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dependencies": [ + "estraverse" + ] + }, + "estraverse@5.3.0": { + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + }, + "esutils@2.0.3": { + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + }, + "fake-indexeddb@6.0.0": { + "integrity": "sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ==" + }, + "fast-deep-equal@3.1.3": { + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-equals@5.0.1": { + "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==" + }, + "fast-glob@3.3.3": { + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dependencies": [ + "@nodelib/fs.stat", + "@nodelib/fs.walk", + "glob-parent@5.1.2", + "merge2", + "micromatch" + ] + }, + "fast-json-stable-stringify@2.1.0": { + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "fast-levenshtein@2.0.6": { + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + }, + "fastq@1.19.1": { + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dependencies": [ + "reusify" + ] + }, + "file-entry-cache@8.0.0": { + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dependencies": [ + "flat-cache" + ] + }, + "fill-range@7.1.1": { + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": [ + "to-regex-range" + ] + }, + "find-up@5.0.0": { + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dependencies": [ + "locate-path", + "path-exists" + ] + }, + "flat-cache@4.0.1": { + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dependencies": [ + "flatted", + "keyv" + ] + }, + "flatted@3.3.3": { + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" + }, + "glob-parent@5.1.2": { + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": [ + "is-glob" + ] + }, + "glob-parent@6.0.2": { + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": [ + "is-glob" + ] + }, + "globals@14.0.0": { + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==" + }, + "graphemer@1.4.0": { + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + }, + "has-flag@4.0.0": { + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "idb@7.1.1": { + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, + "ignore@5.3.2": { + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" + }, + "import-fresh@3.3.1": { + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dependencies": [ + "parent-module", + "resolve-from" + ] + }, + "imurmurhash@0.1.4": { + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + }, + "is-extglob@2.1.1": { + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-glob@4.0.3": { + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": [ + "is-extglob" + ] + }, + "is-number@7.0.0": { + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "isexe@2.0.0": { + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "js-yaml@4.1.0": { + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": [ + "argparse" + ] + }, + "json-buffer@3.0.1": { + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "json-schema-traverse@0.4.1": { + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-stable-stringify-without-jsonify@1.0.1": { + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + }, + "keyv@4.5.4": { + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dependencies": [ + "json-buffer" + ] + }, + "levn@0.4.1": { + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dependencies": [ + "prelude-ls", + "type-check" + ] + }, + "locate-path@6.0.0": { + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dependencies": [ + "p-locate" + ] + }, + "lodash.merge@4.6.2": { + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "memory-pager@1.5.0": { + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, + "merge2@1.4.1": { + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "micromatch@4.0.8": { + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": [ + "braces", + "picomatch" + ] + }, + "mingo@6.4.6": { + "integrity": "sha512-SMp06Eo5iEthCPpKXgEZ6DTZKxknpTqj49YN6iHpapj9DKltBCv0RFu+0mBBjMU0SiHR9pYkurkk74+VFGTqxw==" + }, + "minimatch@3.1.2": { + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": [ + "brace-expansion@1.1.11" + ] + }, + "minimatch@9.0.5": { + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dependencies": [ + "brace-expansion@2.0.1" + ] + }, + "mongodb-connection-string-url@3.0.2": { + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dependencies": [ + "@types/whatwg-url", + "whatwg-url" + ] + }, + "mongodb@6.15.0": { + "integrity": "sha512-ifBhQ0rRzHDzqp9jAQP6OwHSH7dbYIQjD3SbJs9YYk9AikKEettW/9s/tbSFDTpXcRbF+u1aLrhHxDFaYtZpFQ==", + "dependencies": [ + "@mongodb-js/saslprep", + "bson", + "mongodb-connection-string-url" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nanoid@5.0.2": { + "integrity": "sha512-2ustYUX1R2rL/Br5B/FMhi8d5/QzvkJ912rBYxskcpu0myTHzSZfTr1LAS2Sm7jxRUObRrSBFoyzwAhL49aVSg==" + }, + "nanoid@5.1.5": { + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==" + }, + "natural-compare@1.4.0": { + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + }, + "optionator@0.9.4": { + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dependencies": [ + "deep-is", + "fast-levenshtein", + "levn", + "prelude-ls", + "type-check", + "word-wrap" + ] + }, + "p-limit@3.1.0": { + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dependencies": [ + "yocto-queue" + ] + }, + "p-locate@5.0.0": { + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dependencies": [ + "p-limit" + ] + }, + "parent-module@1.0.1": { + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": [ + "callsites" + ] + }, + "path-exists@4.0.0": { + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "path-key@3.1.1": { + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "picomatch@2.3.1": { + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "postgres@3.4.5": { + "integrity": "sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg==" + }, + "prelude-ls@1.2.1": { + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" + }, + "prettier@3.5.3": { + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==" + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "queue-microtask@1.2.3": { + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, + "resolve-from@4.0.0": { + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" + }, + "reusify@1.1.0": { + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" + }, + "rfdc@1.3.0": { + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" + }, + "run-parallel@1.2.0": { + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dependencies": [ + "queue-microtask" + ] + }, + "rxjs@7.8.1": { + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dependencies": [ + "tslib" + ] + }, + "semver@7.7.1": { + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" + }, + "shebang-command@2.0.0": { + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": [ + "shebang-regex" + ] + }, + "shebang-regex@3.0.0": { + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "sparse-bitfield@3.0.3": { + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": [ + "memory-pager" + ] + }, + "strip-json-comments@3.1.1": { + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" + }, + "supports-color@7.2.0": { + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": [ + "has-flag" + ] + }, + "to-regex-range@5.0.1": { + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": [ + "is-number" + ] + }, + "tr46@5.1.1": { + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": [ + "punycode" + ] + }, + "ts-api-utils@2.1.0_typescript@5.8.3": { + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dependencies": [ + "typescript" + ] + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "type-check@0.4.0": { + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dependencies": [ + "prelude-ls" + ] + }, + "type-fest@3.13.1": { + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==" + }, + "typescript-eslint@8.30.1_eslint@9.24.0_typescript@5.8.3_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3": { + "integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==", + "dependencies": [ + "@typescript-eslint/eslint-plugin", + "@typescript-eslint/parser", + "@typescript-eslint/utils", + "eslint", + "typescript" + ] + }, + "typescript@5.8.3": { + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==" + }, + "uri-js@4.4.1": { + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": [ + "punycode" + ] + }, + "webidl-conversions@7.0.0": { + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "whatwg-url@14.2.0": { + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, + "which@2.0.2": { + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": [ + "isexe" + ] + }, + "word-wrap@1.2.5": { + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" + }, + "yocto-queue@0.1.0": { + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + }, + "zod@4.0.0-beta.20250420T053007": { + "integrity": "sha512-5pp8Q0PNDaNcUptGiBE9akyioJh3RJpagIxrLtAVMR9IxwcSZiOsJD/1/98CyhItdTlI2H91MfhhLzRlU+fifA==", + "dependencies": [ + "@zod/core" + ] + } + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__assert@1.0.12", + "npm:@jsr/std__async@1.0.12", + "npm:@jsr/std__testing@1.0.11", + "npm:@jsr/valkyr__testcontainers@2.0.0", + "npm:@valkyr/db@1.0.1", + "npm:eslint-plugin-simple-import-sort@12.1.1", + "npm:eslint@9.24.0", + "npm:fake-indexeddb@6.0.0", + "npm:mongodb@6.15.0", + "npm:nanoid@5.1.5", + "npm:postgres@3.4.5", + "npm:prettier@3.5.3", + "npm:typescript-eslint@8.30.1", + "npm:zod@next" + ] + } + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ff5356c --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,30 @@ +import simpleImportSort from "eslint-plugin-simple-import-sort"; +import tseslint from "typescript-eslint"; + +export default [ + ...tseslint.configs.recommended, + { + plugins: { + "simple-import-sort": simpleImportSort, + }, + rules: { + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + }, + }, + { + files: ["**/*.ts"], + rules: { + "@typescript-eslint/ban-ts-comment": ["error", { + "ts-expect-error": "allow-with-description", + minimumDescriptionLength: 10, + }], + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": ["error", { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }], + }, + }, +]; diff --git a/libraries/aggregate-factory.ts b/libraries/aggregate-factory.ts new file mode 100644 index 0000000..f0b170b --- /dev/null +++ b/libraries/aggregate-factory.ts @@ -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[] = AggregateRootClass[], +> { + /** + * Optimized aggregate lookup index. + */ + readonly #index = new Map(); + + 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(name: TName): Extract { + return this.#index.get(name) as Extract; + } +} diff --git a/libraries/aggregate.ts b/libraries/aggregate.ts new file mode 100644 index 0000000..9c7526f --- /dev/null +++ b/libraries/aggregate.ts @@ -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 { + /** + * 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>( + this: TAggregateRoot, + snapshot?: Unknown, + ): InstanceType { + 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( + record: { type: TType } & Extract["$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 { + 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 = typeof AggregateRoot; diff --git a/libraries/errors.ts b/libraries/errors.ts new file mode 100644 index 0000000..9372bc5 --- /dev/null +++ b/libraries/errors.ts @@ -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.`); + } +} diff --git a/libraries/event-factory.ts b/libraries/event-factory.ts new file mode 100644 index 0000000..ee6cff1 --- /dev/null +++ b/libraries/event-factory.ts @@ -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 { + /** + * Optimized event lookup index. + */ + readonly #index = new Map(); + + /** + * 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(type: TType): Extract { + return this.#index.get(type) as Extract; + } +} diff --git a/libraries/event-store.ts b/libraries/event-store.ts new file mode 100644 index 0000000..7954d8f --- /dev/null +++ b/libraries/event-store.ts @@ -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, + TEventStoreAdapter extends EventStoreAdapter, +> { + readonly #adapter: TEventStoreAdapter; + readonly #events: TEventFactory; + readonly #aggregates: TAggregateFactory; + readonly #snapshot: "manual" | "auto"; + readonly #hooks: EventStoreHooks; + + declare readonly $events: TEventFactory["$events"]; + declare readonly $records: TEventFactory["$events"][number]["$record"][]; + + constructor(config: EventStoreConfig) { + 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["onEventsInserted"]) { + this.#hooks.onEventsInserted = fn; + } + + /* + |-------------------------------------------------------------------------------- + | Aggregates + |-------------------------------------------------------------------------------- + */ + + /** + * Get aggregate uninstantiated class. + * + * @param name - Aggregate name to retrieve. + */ + aggregate(name: TName): Extract { + return this.#aggregates.get(name) as Extract; + } + + /** + * 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, settings?: EventsInsertSettings): Promise { + 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[], settings?: EventsInsertSettings): Promise { + 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( + payload: { type: TType } & Extract["$payload"], + ): Extract["$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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(foldFn: ReducerLeftFold, stateFn: ReducerState): Reducer { + return makeReducer(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 { + * name: string = ""; + * + * static #reducer = makeAggregateReducer(Foo); + * + * static async getById(fooId: string): Promise { + * 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>(aggregate: TAggregateRoot): Reducer> { + return makeAggregateReducer(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( + { name, stream, relation, reducer, ...query }: ReduceQuery, + pending: TEventFactory["$events"][number]["$record"][] = [], + ): Promise | undefined> { + const id = stream ?? relation; + + let state: InferReducerState | 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({ name, stream, relation, reducer, ...query }: ReduceQuery): Promise { + 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>( + 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 { + await this.snapshots.remove(name, streamOrRelation); + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type EventStoreConfig< + TEventFactory extends EventFactory, + TAggregateFactory extends AggregateFactory, + TEventStoreAdapter extends EventStoreAdapter, +> = { + adapter: TEventStoreAdapter; + events: TEventFactory; + aggregates: TAggregateFactory; + snapshot?: "manual" | "auto"; + hooks?: EventStoreHooks; +}; + +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 = 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; + + /** + * Triggered when an unhandled exception is thrown during `.pushEvent` and + * `.pushManyEvents` hook. + * + * @param error - Error that was thrown. + */ + onError(error: unknown): Promise; +}>; + +export type AnyEventStore = EventStore; diff --git a/libraries/event.ts b/libraries/event.ts new file mode 100644 index 0000000..5087af9 --- /dev/null +++ b/libraries/event.ts @@ -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 { + declare readonly $record: EventRecord; + declare readonly $payload: EventPayload; + + 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(data: TData): Event & { data: TData }> { + return new Event & { 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(meta: TMeta): Event & { meta: TMeta }> { + return new Event & { 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): EventRecord { + 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): 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(type: TType): Event<{ type: TType }> { + return new Event<{ type: TType }>({ type }); + }, +}; + +type EventState = { + type: string; + data?: ZodType; + meta?: ZodType; +}; + +export type EventPayload = { stream?: string } & (TEventState["data"] extends ZodType + ? { data: z.infer } + : object) & + (TEventState["meta"] extends ZodType ? { meta: z.infer } : object); + +type EventValidationResult = + | { + success: true; + } + | { + success: false; + errors: any[]; + }; + +/** + * Event that has been persisted to a event store solution. + */ +export type EventRecord = { + /** + * 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 : 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 : 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; +}; diff --git a/libraries/hlc.ts b/libraries/hlc.ts new file mode 100644 index 0000000..8e32c51 --- /dev/null +++ b/libraries/hlc.ts @@ -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; + }; +}; diff --git a/libraries/nanoid.ts b/libraries/nanoid.ts new file mode 100644 index 0000000..bc14a69 --- /dev/null +++ b/libraries/nanoid.ts @@ -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); +} diff --git a/libraries/projector.ts b/libraries/projector.ts new file mode 100644 index 0000000..6707fe0 --- /dev/null +++ b/libraries/projector.ts @@ -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({ + allowHydratedEvents: false, + allowOutdatedEvents: false, +}); + +const FILTER_CONTINUOUS = Object.freeze({ + allowHydratedEvents: true, + allowOutdatedEvents: false, +}); + +const FILTER_ALL = Object.freeze({ + 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 { + #listeners: ProjectorListeners = {}; + #batchedListeners: BatchedProjectorListeners = {}; + #queues: { + [stream: string]: Queue>; + } = {}; + + 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 { + return new Promise((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 { + 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): 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, + TSuccessData extends Record | void = void, + >( + type: TType, + handler: ProjectionHandler, + effects: TSuccessData extends void + ? { + onError(res: { error: unknown; record: TRecord }): Promise; + onSuccess(res: { record: TRecord }): Promise; + } + : { + onError(res: { error: unknown; record: TRecord }): Promise; + onSuccess(res: { data: TSuccessData; record: TRecord }): Promise; + }, + ): 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, + >(type: TType, handler: ProjectionHandler): 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, + >(type: TType, handler: ProjectionHandler): 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, + effects?: { + onError(res: { error: unknown; record: TEventFactory["$events"][number]["$record"] }): Promise; + onSuccess(res: { data?: unknown; record: TEventFactory["$events"][number]["$record"] }): Promise; + }, + ): { 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): () => 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; + } +} diff --git a/libraries/queue.ts b/libraries/queue.ts new file mode 100644 index 0000000..b390b08 --- /dev/null +++ b/libraries/queue.ts @@ -0,0 +1,100 @@ +export class Queue { + status: Status; + + #queue: Message[]; + #handle: Handler; + #hooks: Hooks; + + constructor(handler: Handler, 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>): this { + if (filter) { + this.#queue = this.#queue.filter(filter); + } else { + this.#queue = []; + } + return this; + } + + /* + |-------------------------------------------------------------------------------- + | Processor + |-------------------------------------------------------------------------------- + */ + + async #process(): Promise { + 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 = (message: T) => Promise | Promise; + +type Hooks = { + onDrained?: () => void; +}; + +type Message = { + message: T; +} & MessagePromise; + +type MessagePromise = { + resolve: (value: any) => void; + reject: (reason?: any) => void; +}; + +type Filter = (job: T) => boolean; diff --git a/libraries/reducer.ts b/libraries/reducer.ts new file mode 100644 index 0000000..cbc1e49 --- /dev/null +++ b/libraries/reducer.ts @@ -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>( + aggregate: TAggregateRoot, +): Reducer> { + 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( + foldFn: ReducerLeftFold, + stateFn: ReducerState, +): Reducer { + 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 | AggregateRoot = 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 = any, TEventFactory extends EventFactory = EventFactory> = ( + state: TState, + event: TEventFactory["$events"][number]["$record"], +) => TState; + +export type ReducerState = () => TState; + +export type InferReducerState = TReducer extends Reducer ? TState : never; diff --git a/libraries/time.ts b/libraries/time.ts new file mode 100644 index 0000000..95956d6 --- /dev/null +++ b/libraries/time.ts @@ -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; +} diff --git a/libraries/timestamp.ts b/libraries/timestamp.ts new file mode 100644 index 0000000..99a1993 --- /dev/null +++ b/libraries/timestamp.ts @@ -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; +}; diff --git a/libraries/zod.ts b/libraries/zod.ts new file mode 100644 index 0000000..61d8681 --- /dev/null +++ b/libraries/zod.ts @@ -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(""); +} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..6b20164 --- /dev/null +++ b/mod.ts @@ -0,0 +1,15 @@ +export * from "./libraries/aggregate.ts"; +export * from "./libraries/errors.ts"; +export * from "./libraries/event.ts"; +export * from "./libraries/event-store.ts"; +export * from "./libraries/nanoid.ts"; +export * from "./libraries/projector.ts"; +export * from "./libraries/queue.ts"; +export * from "./libraries/reducer.ts"; +export * from "./libraries/time.ts"; +export * from "./libraries/timestamp.ts"; +export type * from "./types/adapter.ts"; +export type * from "./types/common.ts"; +export type * from "./types/projector.ts"; +export type * from "./types/query.ts"; +export type * from "./types/utilities.ts"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..115dbb5 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "dependencies": { + "@std/async": "npm:@jsr/std__async@1.0.12", + "@valkyr/db": "1.0.1", + "mongodb": "6.15.0", + "nanoid": "5.1.5", + "postgres": "3.4.5", + "zod": "next" + }, + "devDependencies": { + "@std/assert": "npm:@jsr/std__assert@1.0.12", + "@std/testing": "npm:@jsr/std__testing@1.0.11", + "@valkyr/testcontainers": "npm:@jsr/valkyr__testcontainers@2.0.0", + "eslint": "9.24.0", + "eslint-plugin-simple-import-sort": "12.1.1", + "fake-indexeddb": "6.0.0", + "prettier": "3.5.3", + "typescript-eslint": "8.30.1" + } +} diff --git a/tests/adapters/browser-iddb.test.ts b/tests/adapters/browser-iddb.test.ts new file mode 100644 index 0000000..6e0a817 --- /dev/null +++ b/tests/adapters/browser-iddb.test.ts @@ -0,0 +1,81 @@ +import "fake-indexeddb/auto"; + +import { delay } from "@std/async"; +import { afterAll, describe } from "@std/testing/bdd"; + +import { BrowserAdapter } from "../../adapters/browser/adapter.ts"; +import { EventStore, EventStoreHooks } from "../../libraries/event-store.ts"; +import { Projector } from "../../libraries/projector.ts"; +import { aggregates } from "./mocks/aggregates.ts"; +import { events, EventStoreFactory } from "./mocks/events.ts"; +import testAddEvent from "./store/add-event.ts"; +import testCreateSnapshot from "./store/create-snapshot.ts"; +import testMakeAggregateReducer from "./store/make-aggregate-reducer.ts"; +import testMakeReducer from "./store/make-reducer.ts"; +import testOnceProjection from "./store/once-projection.ts"; +import testPushAggregate from "./store/push-aggregate.ts"; +import testPushManyAggregates from "./store/push-many-aggregates.ts"; +import testReduce from "./store/reduce.ts"; +import testReplayEvents from "./store/replay-events.ts"; + +const eventStoreFn = async (options: { hooks?: EventStoreHooks } = {}) => getEventStore(options); + +/* + |-------------------------------------------------------------------------------- + | Lifecycle + |-------------------------------------------------------------------------------- + */ + +afterAll(async () => { + await delay(250); +}); + +/* + |-------------------------------------------------------------------------------- + | Tests + |-------------------------------------------------------------------------------- + */ + +describe("Adapter > Browser (IndexedDb)", () => { + testAddEvent(eventStoreFn); + testCreateSnapshot(eventStoreFn); + testMakeReducer(eventStoreFn); + testMakeAggregateReducer(eventStoreFn); + testReplayEvents(eventStoreFn); + testReduce(eventStoreFn); + testOnceProjection(eventStoreFn); + + testPushAggregate(eventStoreFn); + testPushManyAggregates(eventStoreFn); +}); + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +function getEventStore({ hooks = {} }: { hooks?: EventStoreHooks }) { + const store = new EventStore({ + adapter: new BrowserAdapter("indexeddb"), + events, + aggregates, + hooks, + }); + + const projector = new Projector(); + + if (hooks.onEventsInserted === undefined) { + store.onEventsInserted(async (records, { batch }) => { + if (batch !== undefined) { + await projector.pushMany(batch, records); + } else { + for (const record of records) { + await projector.push(record, { hydrated: false, outdated: false }); + } + } + }); + } + + return { store, projector }; +} diff --git a/tests/adapters/browser-memory.test.ts b/tests/adapters/browser-memory.test.ts new file mode 100644 index 0000000..c4234df --- /dev/null +++ b/tests/adapters/browser-memory.test.ts @@ -0,0 +1,70 @@ +import "fake-indexeddb/auto"; + +import { describe } from "@std/testing/bdd"; + +import { BrowserAdapter } from "../../adapters/browser/adapter.ts"; +import { EventStore, EventStoreHooks } from "../../libraries/event-store.ts"; +import { Projector } from "../../libraries/projector.ts"; +import { aggregates } from "./mocks/aggregates.ts"; +import { events, EventStoreFactory } from "./mocks/events.ts"; +import testAddEvent from "./store/add-event.ts"; +import testCreateSnapshot from "./store/create-snapshot.ts"; +import testMakeAggregateReducer from "./store/make-aggregate-reducer.ts"; +import testMakeReducer from "./store/make-reducer.ts"; +import testOnceProjection from "./store/once-projection.ts"; +import testPushAggregate from "./store/push-aggregate.ts"; +import testPushManyAggregates from "./store/push-many-aggregates.ts"; +import testReduce from "./store/reduce.ts"; +import testReplayEvents from "./store/replay-events.ts"; + +const eventStoreFn = async (options: { hooks?: EventStoreHooks } = {}) => getEventStore(options); + +/* + |-------------------------------------------------------------------------------- + | Tests + |-------------------------------------------------------------------------------- + */ + +describe("Adapter > Browser (memory)", () => { + testAddEvent(eventStoreFn); + testCreateSnapshot(eventStoreFn); + testMakeReducer(eventStoreFn); + testMakeAggregateReducer(eventStoreFn); + testReplayEvents(eventStoreFn); + testReduce(eventStoreFn); + testOnceProjection(eventStoreFn); + + testPushAggregate(eventStoreFn); + testPushManyAggregates(eventStoreFn); +}); + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +function getEventStore({ hooks = {} }: { hooks?: EventStoreHooks }) { + const store = new EventStore({ + adapter: new BrowserAdapter("memorydb"), + events, + aggregates, + hooks, + }); + + const projector = new Projector(); + + if (hooks.onEventsInserted === undefined) { + store.onEventsInserted(async (records, { batch }) => { + if (batch !== undefined) { + await projector.pushMany(batch, records); + } else { + for (const record of records) { + await projector.push(record, { hydrated: false, outdated: false }); + } + } + }); + } + + return { store, projector }; +} diff --git a/tests/adapters/mocks/aggregates.ts b/tests/adapters/mocks/aggregates.ts new file mode 100644 index 0000000..90968a0 --- /dev/null +++ b/tests/adapters/mocks/aggregates.ts @@ -0,0 +1,134 @@ +import { AggregateRoot } from "../../../libraries/aggregate.ts"; +import { AggregateFactory } from "../../../libraries/aggregate-factory.ts"; +import { makeId } from "../../../libraries/nanoid.ts"; +import { makeAggregateReducer } from "../../../libraries/reducer.ts"; +import { EventStoreFactory } from "./events.ts"; + +export class User extends AggregateRoot { + static override readonly name = "user"; + + id: string = ""; + name: Name = { + given: "", + family: "", + }; + email: string = ""; + active: boolean = true; + posts: UserPosts = { + list: [], + count: 0, + }; + + // ------------------------------------------------------------------------- + // Factories + // ------------------------------------------------------------------------- + + static reducer = makeAggregateReducer(User); + + static create(name: Name, email: string): User { + const user = new User(); + user.push({ + type: "user:created", + stream: makeId(), + data: { name, email }, + meta: { auditor: "foo" }, + }); + return user; + } + + static async getById(userId: string): Promise { + return this.$store.reduce({ name: "user", stream: userId, reducer: this.reducer }); + } + + // ------------------------------------------------------------------------- + // Reducer + // ------------------------------------------------------------------------- + + with(event: EventStoreFactory["$events"][number]["$record"]) { + switch (event.type) { + case "user:created": { + this.id = event.stream; + this.name.given = event.data.name?.given ?? ""; + this.name.family = event.data.name?.family ?? ""; + this.email = event.data.email; + break; + } + case "user:name:given-set": { + this.name.given = event.data; + break; + } + case "user:name:family-set": { + this.name.family = event.data; + break; + } + case "user:email-set": { + this.email = event.data; + break; + } + case "user:activated": { + this.active = true; + break; + } + case "user:deactivated": { + this.active = false; + break; + } + } + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + setGivenName(given: string): this { + return this.push({ + type: "user:name:given-set", + stream: this.id, + data: given, + meta: { auditor: "foo" }, + }); + } + + setFamilyName(family: string): this { + return this.push({ + type: "user:name:family-set", + stream: this.id, + data: family, + meta: { auditor: "foo" }, + }); + } + + setEmail(email: string, auditor: string): this { + return this.push({ + type: "user:email-set", + stream: this.id, + data: email, + meta: { auditor }, + }); + } + + async snapshot(): Promise { + await this.$store.createSnapshot({ name: "user", stream: this.id, reducer: User.reducer }); + return this; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + fullName(): string { + return `${this.name.given} ${this.name.family}`; + } +} + +export const aggregates = new AggregateFactory([User]); + +type Name = { + given: string; + family: string; +}; + +type UserPosts = { + list: string[]; + count: number; +}; diff --git a/tests/adapters/mocks/errors.ts b/tests/adapters/mocks/errors.ts new file mode 100644 index 0000000..17fabf7 --- /dev/null +++ b/tests/adapters/mocks/errors.ts @@ -0,0 +1,19 @@ +export abstract class ServiceError extends Error { + constructor(message: string, readonly status: number, readonly data?: TData) { + super(message); + } + + toJSON() { + return { + status: this.status, + message: this.message, + data: this.data, + }; + } +} + +export class CustomServiceError extends ServiceError { + constructor(message = "Custom Error", data?: TData) { + super(message, 400, data); + } +} diff --git a/tests/adapters/mocks/events.ts b/tests/adapters/mocks/events.ts new file mode 100644 index 0000000..55981b4 --- /dev/null +++ b/tests/adapters/mocks/events.ts @@ -0,0 +1,32 @@ +import z from "zod"; + +import { event } from "../../../libraries/event.ts"; +import { EventFactory } from "../../../libraries/event-factory.ts"; + +export const auditor = z.strictObject({ auditor: z.string() }); + +export const events = new EventFactory([ + event + .type("user:created") + .data( + z.strictObject({ + name: z + .union([z.strictObject({ given: z.string(), family: z.string().optional() }), z.strictObject({ given: z.string().optional(), family: z.string() })]) + .optional(), + email: z.string(), + }), + ) + .meta(auditor), + event.type("user:name:given-set").data(z.string()).meta(auditor), + event.type("user:name:family-set").data(z.string()).meta(auditor), + event.type("user:email-set").data(z.email()).meta(auditor), + event.type("user:activated").meta(auditor), + event.type("user:deactivated").meta(auditor), + event + .type("post:created") + .data(z.strictObject({ title: z.string(), body: z.string() })) + .meta(auditor), + event.type("post:removed").meta(auditor), +]); + +export type EventStoreFactory = typeof events; diff --git a/tests/adapters/mocks/user-posts-reducer.ts b/tests/adapters/mocks/user-posts-reducer.ts new file mode 100644 index 0000000..05117c6 --- /dev/null +++ b/tests/adapters/mocks/user-posts-reducer.ts @@ -0,0 +1,32 @@ +import { makeReducer } from "../../../libraries/reducer.ts"; +import { EventStoreFactory } from "./events.ts"; + +export const userPostReducer = makeReducer( + (state, event) => { + switch (event.type) { + case "post:created": { + state.posts.push({ id: event.stream, author: event.meta.auditor }); + state.count += 1; + break; + } + case "post:removed": { + state.posts = state.posts.filter(({ id }) => id !== event.stream); + state.count -= 1; + break; + } + } + return state; + }, + () => ({ + posts: [], + count: 0, + }), +); + +type UserPostState = { + posts: { + id: string; + author: string; + }[]; + count: number; +}; diff --git a/tests/adapters/mocks/user-reducer.ts b/tests/adapters/mocks/user-reducer.ts new file mode 100644 index 0000000..91d3f80 --- /dev/null +++ b/tests/adapters/mocks/user-reducer.ts @@ -0,0 +1,61 @@ +import { makeReducer } from "../../../libraries/reducer.ts"; +import { EventStoreFactory } from "./events.ts"; + +export const userReducer = makeReducer( + (state, event) => { + switch (event.type) { + case "user:created": { + state.name.given = event.data.name?.given ?? ""; + state.name.family = event.data.name?.family ?? ""; + state.email = event.data.email; + break; + } + case "user:name:given-set": { + state.name.given = event.data; + break; + } + case "user:name:family-set": { + state.name.family = event.data; + break; + } + case "user:email-set": { + state.email = event.data; + break; + } + case "user:activated": { + state.active = true; + break; + } + case "user:deactivated": { + state.active = false; + break; + } + } + return state; + }, + () => ({ + name: { + given: "", + family: "", + }, + email: "", + active: true, + posts: { + list: [], + count: 0, + }, + }), +); + +type UserState = { + name: { + given: string; + family: string; + }; + email: string; + active: boolean; + posts: { + list: string[]; + count: number; + }; +}; diff --git a/tests/adapters/mongodb.test.ts b/tests/adapters/mongodb.test.ts new file mode 100644 index 0000000..9877e02 --- /dev/null +++ b/tests/adapters/mongodb.test.ts @@ -0,0 +1,99 @@ +import { afterAll, afterEach, beforeAll, describe } from "@std/testing/bdd"; +import { MongoTestContainer } from "@valkyr/testcontainers/mongodb"; + +import { MongoAdapter, register } from "../../adapters/mongo/adapter.ts"; +import { EventStore, EventStoreHooks } from "../../libraries/event-store.ts"; +import { Projector } from "../../libraries/projector.ts"; +import { aggregates } from "./mocks/aggregates.ts"; +import { events, EventStoreFactory } from "./mocks/events.ts"; +import testAddEvent from "./store/add-event.ts"; +import testAddManyEvents from "./store/add-many-events.ts"; +import testCreateSnapshot from "./store/create-snapshot.ts"; +import testMakeAggregateReducer from "./store/make-aggregate-reducer.ts"; +import testMakeEvent from "./store/make-event.ts"; +import testMakeReducer from "./store/make-reducer.ts"; +import testOnceProjection from "./store/once-projection.ts"; +import testRelationsProvider from "./store/providers/relations.ts"; +import testPushAggregate from "./store/push-aggregate.ts"; +import testPushManyAggregates from "./store/push-many-aggregates.ts"; +import testReduce from "./store/reduce.ts"; +import testReplayEvents from "./store/replay-events.ts"; + +const DB_NAME = "sandbox"; + +const container = await MongoTestContainer.start(); + +const eventStoreFn = async (options: { hooks?: EventStoreHooks } = {}) => getEventStore(options); + +/* + |-------------------------------------------------------------------------------- + | Database + |-------------------------------------------------------------------------------- + */ + +beforeAll(async () => { + const db = container.client.db(DB_NAME); + await register(db, console.info); +}); + +afterEach(async () => { + const db = container.client.db(DB_NAME); + await Promise.all([db.collection("events").deleteMany({}), db.collection("relations").deleteMany({}), db.collection("snapshots").deleteMany({})]); +}); + +afterAll(async () => { + await container.stop(); +}); + +/* + |-------------------------------------------------------------------------------- + | Tests + |-------------------------------------------------------------------------------- + */ + +describe("Adapter > MongoDb", () => { + testRelationsProvider(eventStoreFn); + testAddEvent(eventStoreFn); + testAddManyEvents(eventStoreFn); + testCreateSnapshot(eventStoreFn); + testMakeEvent(eventStoreFn); + testMakeReducer(eventStoreFn); + testMakeAggregateReducer(eventStoreFn); + testReplayEvents(eventStoreFn); + testReduce(eventStoreFn); + testOnceProjection(eventStoreFn); + + testPushAggregate(eventStoreFn); + testPushManyAggregates(eventStoreFn); +}); + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +async function getEventStore({ hooks = {} }: { hooks?: EventStoreHooks }) { + const store = new EventStore({ + adapter: new MongoAdapter(() => container.client, DB_NAME), + events, + aggregates, + hooks, + }); + + const projector = new Projector(); + + if (hooks.onEventsInserted === undefined) { + store.onEventsInserted(async (records, { batch }) => { + if (batch !== undefined) { + await projector.pushMany(batch, records); + } else { + for (const record of records) { + await projector.push(record, { hydrated: false, outdated: false }); + } + } + }); + } + + return { store, projector }; +} diff --git a/tests/adapters/postgres.test.ts b/tests/adapters/postgres.test.ts new file mode 100644 index 0000000..fdef99c --- /dev/null +++ b/tests/adapters/postgres.test.ts @@ -0,0 +1,137 @@ +import { afterAll, afterEach, beforeAll, describe } from "@std/testing/bdd"; +import { PostgresTestContainer } from "@valkyr/testcontainers/postgres"; +import postgres from "postgres"; + +import { PostgresAdapter } from "../../adapters/postgres/adapter.ts"; +import type { PostgresConnection } from "../../adapters/postgres/connection.ts"; +import { EventStore, type EventStoreHooks } from "../../libraries/event-store.ts"; +import { Projector } from "../../libraries/projector.ts"; +import { aggregates } from "./mocks/aggregates.ts"; +import { events, EventStoreFactory } from "./mocks/events.ts"; +import testAddEvent from "./store/add-event.ts"; +import testAddManyEvents from "./store/add-many-events.ts"; +import testCreateSnapshot from "./store/create-snapshot.ts"; +import testMakeAggregateReducer from "./store/make-aggregate-reducer.ts"; +import testMakeEvent from "./store/make-event.ts"; +import testMakeReducer from "./store/make-reducer.ts"; +import testOnceProjection from "./store/once-projection.ts"; +import testRelationsProvider from "./store/providers/relations.ts"; +import testPushAggregate from "./store/push-aggregate.ts"; +import testPushManyAggregates from "./store/push-many-aggregates.ts"; +import testReduce from "./store/reduce.ts"; +import testReplayEvents from "./store/replay-events.ts"; + +const DB_NAME = "sandbox"; + +const container = await PostgresTestContainer.start("postgres:17"); +const sql = postgres(container.url(DB_NAME)); + +const eventStoreFn = async (options: { hooks?: EventStoreHooks } = {}) => getEventStore(sql, options); + +/* + |-------------------------------------------------------------------------------- + | Database + |-------------------------------------------------------------------------------- + */ + +beforeAll(async () => { + await container.create(DB_NAME); + await sql`CREATE SCHEMA "event_store"`; + await sql` + CREATE TABLE IF NOT EXISTS "event_store"."events" ( + "id" varchar PRIMARY KEY NOT NULL, + "stream" varchar NOT NULL, + "type" varchar NOT NULL, + "data" jsonb NOT NULL, + "meta" jsonb NOT NULL, + "recorded" varchar NOT NULL, + "created" varchar NOT NULL + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS "event_store"."relations" ( + "id" serial PRIMARY KEY NOT NULL, + "key" varchar NOT NULL, + "stream" varchar NOT NULL, + UNIQUE ("key", "stream") + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS "event_store"."snapshots" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "stream" varchar NOT NULL, + "cursor" varchar NOT NULL, + "state" jsonb NOT NULL, + UNIQUE ("name", "stream") + ) + `; + await sql`CREATE INDEX IF NOT EXISTS "relations_key_index" ON "event_store"."relations" USING btree ("key")`; + await sql`CREATE INDEX IF NOT EXISTS "relations_stream_index" ON "event_store"."relations" USING btree ("stream")`; + await sql`CREATE INDEX IF NOT EXISTS "events_stream_index" ON "event_store"."events" USING btree ("stream")`; + await sql`CREATE INDEX IF NOT EXISTS "events_type_index" ON "event_store"."events" USING btree ("type")`; + await sql`CREATE INDEX IF NOT EXISTS "events_recorded_index" ON "event_store"."events" USING btree ("recorded")`; + await sql`CREATE INDEX IF NOT EXISTS "events_created_index" ON "event_store"."events" USING btree ("created")`; + await sql`CREATE INDEX IF NOT EXISTS "snapshots_name_stream_cursor_index" ON "event_store"."snapshots" USING btree ("name","stream","cursor")`; +}); + +afterEach(async () => { + await container.client(DB_NAME)`TRUNCATE "event_store"."relations","event_store"."events","event_store"."snapshots" CASCADE`; +}); + +afterAll(async () => { + await container.stop(); +}); + +/* + |-------------------------------------------------------------------------------- + | Tests + |-------------------------------------------------------------------------------- + */ + +describe("Adapter > Postgres", () => { + testRelationsProvider(eventStoreFn); + testAddEvent(eventStoreFn); + testAddManyEvents(eventStoreFn); + testCreateSnapshot(eventStoreFn); + testMakeEvent(eventStoreFn); + testMakeReducer(eventStoreFn); + testMakeAggregateReducer(eventStoreFn); + testReplayEvents(eventStoreFn); + testReduce(eventStoreFn); + testOnceProjection(eventStoreFn); + + testPushAggregate(eventStoreFn); + testPushManyAggregates(eventStoreFn); +}); + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +async function getEventStore(connection: PostgresConnection, { hooks = {} }: { hooks?: EventStoreHooks }) { + const store = new EventStore({ + adapter: new PostgresAdapter(connection, { schema: "event_store" }), + events, + aggregates, + hooks, + }); + + const projector = new Projector(); + + if (hooks.onEventsInserted === undefined) { + store.onEventsInserted(async (records, { batch }) => { + if (batch !== undefined) { + await projector.pushMany(batch, records); + } else { + for (const record of records) { + await projector.push(record, { hydrated: false, outdated: false }); + } + } + }); + } + + return { store, projector }; +} diff --git a/tests/adapters/store/add-event.ts b/tests/adapters/store/add-event.ts new file mode 100644 index 0000000..131b1fb --- /dev/null +++ b/tests/adapters/store/add-event.ts @@ -0,0 +1,220 @@ +import { assertEquals, assertObjectMatch, assertRejects } from "@std/assert"; +import { it } from "@std/testing/bdd"; + +import { EventInsertionError, EventValidationError } from "../../../libraries/errors.ts"; +import { makeId } from "../../../libraries/nanoid.ts"; +import type { EventStoreFactory } from "../mocks/events.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe(".addEvent", (getEventStore) => { + it("should throw a 'EventValidationError' when providing bad event data", async () => { + const { store } = await getEventStore(); + + await assertRejects( + async () => + store.pushEvent( + store.event({ + type: "user:created", + data: { + name: { + given: "John", + familys: "Doe", + }, + email: "john.doe@fixture.none", + }, + } as any), + ), + EventValidationError, + ); + }); + + it("should throw a 'EventInsertionError' on event insertion error", async () => { + const { store } = await getEventStore(); + + store.events.insert = async () => { + throw new Error("Fake Insert Error"); + }; + + await assertRejects( + async () => + store.pushEvent( + store.event({ + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { auditor: "foo" }, + }), + ), + EventInsertionError, + new EventInsertionError().message, + ); + }); + + it("should insert and project 'user:created' event", async () => { + const { store, projector } = await getEventStore(); + + const stream = makeId(); + const event = store.event({ + stream, + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { auditor: "foo" }, + }); + + let projectedResult: string = ""; + + projector.on("user:created", async (record) => { + projectedResult = `${record.data.name?.given} ${record.data.name?.family} | ${record.data.email}`; + }); + + await store.pushEvent(event); + + assertObjectMatch(await store.events.getByStream(stream).then((rows: any) => rows[0]), event); + assertEquals(projectedResult, "John Doe | john.doe@fixture.none"); + }); + + it("should insert 'user:created' and ignore 'project' error", async () => { + const { store, projector } = await getEventStore({ + hooks: { + async onError() { + // ... + }, + }, + }); + + const stream = makeId(); + const event = store.event({ + stream, + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "admin", + }, + }); + + projector.on("user:created", async () => { + throw new Error(); + }); + + await store.pushEvent(event); + + assertObjectMatch(await store.events.getByStream(stream).then((rows: any) => rows[0]), event); + }); + + it("should insert 'user:created' and add it to 'tenant:xyz' relation", async () => { + const { store, projector } = await getEventStore(); + + const key = `tenant:${makeId()}`; + + projector.on("user:created", async ({ stream }) => { + await store.relations.insert(key, stream); + }); + + await store.pushEvent( + store.event({ + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "admin", + }, + }), + ); + + const res1 = await store.getEventsByRelations([key]); + + assertEquals(res1.length, 1); + + await store.pushEvent( + store.event({ + type: "user:created", + data: { + name: { + given: "Jane", + family: "Doe", + }, + email: "jane.doe@fixture.none", + }, + meta: { + auditor: "admin", + }, + }), + ); + + const res2 = await store.getEventsByRelations([key]); + + assertEquals(res2.length, 2); + }); + + it("should insert 'user:email-set' and remove it from 'tenant:xyz' relations", async () => { + const { store, projector } = await getEventStore(); + + const key = `tenant:${makeId()}`; + + projector.on("user:created", async ({ stream }) => { + await store.relations.insert(key, stream); + }); + + projector.on("user:email-set", async ({ stream }) => { + await store.relations.remove(key, stream); + }); + + await store.pushEvent( + store.event({ + stream: "user-1", + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "admin", + }, + }), + ); + + const res1 = await store.getEventsByRelations([key]); + + assertEquals(res1.length, 1); + + await store.pushEvent( + store.event({ + stream: "user-1", + type: "user:email-set", + data: "jane.doe@fixture.none", + meta: { + auditor: "super", + }, + }), + ); + + const res2 = await store.getEventsByRelations([key]); + + assertEquals(res2.length, 0); + }); +}); diff --git a/tests/adapters/store/add-many-events.ts b/tests/adapters/store/add-many-events.ts new file mode 100644 index 0000000..27e4be0 --- /dev/null +++ b/tests/adapters/store/add-many-events.ts @@ -0,0 +1,108 @@ +import { assertEquals, assertObjectMatch, assertRejects } from "@std/assert"; +import { it } from "@std/testing/bdd"; +import { nanoid } from "nanoid"; + +import { EventValidationError } from "../../../mod.ts"; +import type { EventStoreFactory } from "../mocks/events.ts"; +import { userReducer } from "../mocks/user-reducer.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe(".addSequence", (getEventStore) => { + it("should insert 'user:created', 'user:name:given-set', and 'user:email-set' in a sequence of events", async () => { + const { store } = await getEventStore(); + const stream = nanoid(); + + const events = [ + store.event({ + stream, + type: "user:created", + data: { + name: { + given: "Jane", + family: "Doe", + }, + email: "jane.doe@fixture.none", + }, + meta: { + auditor: "admin", + }, + }), + store.event({ + stream, + type: "user:name:given-set", + data: "John", + meta: { + auditor: "admin", + }, + }), + store.event({ + stream, + type: "user:email-set", + data: "john@doe.com", + meta: { + auditor: "admin", + }, + }), + ]; + + await store.pushManyEvents(events); + + const records = await store.getEventsByStreams([stream]); + + assertEquals(records.length, 3); + + records.forEach((record, index) => { + assertObjectMatch(record, events[index]); + }); + + const state = await store.reduce({ name: "user", stream, reducer: userReducer }); + + assertEquals(state?.name.given, "John"); + assertEquals(state?.email, "john@doe.com"); + }); + + it("should not commit any events when insert fails", async () => { + const { store } = await getEventStore(); + const stream = nanoid(); + + await assertRejects( + async () => + store.pushManyEvents([ + store.event({ + stream, + type: "user:created", + data: { + name: { + given: "Jane", + family: "Doe", + }, + email: "jane.doe@fixture.none", + }, + meta: { + auditor: "admin", + }, + }), + store.event({ + stream, + type: "user:name:given-set", + data: { + givens: "John", + }, + } as any), + store.event({ + stream, + type: "user:email-set", + data: "john@doe.com", + meta: { + auditor: "admin", + }, + }), + ]), + EventValidationError, + ); + + const records = await store.getEventsByStreams([stream]); + + assertEquals(records.length, 0); + }); +}); diff --git a/tests/adapters/store/create-snapshot.ts b/tests/adapters/store/create-snapshot.ts new file mode 100644 index 0000000..2228235 --- /dev/null +++ b/tests/adapters/store/create-snapshot.ts @@ -0,0 +1,91 @@ +import { assertEquals, assertNotEquals, assertObjectMatch } from "@std/assert"; +import { it } from "@std/testing/bdd"; +import { nanoid } from "nanoid"; + +import type { EventStoreFactory } from "../mocks/events.ts"; +import { userReducer } from "../mocks/user-reducer.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe(".createSnapshot", (getEventStore) => { + it("should create a new snapshot", async () => { + const { store } = await getEventStore(); + const stream = nanoid(); + + await store.pushEvent( + store.event({ + stream, + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "super", + }, + }), + ); + + await store.pushEvent( + store.event({ + stream, + type: "user:email-set", + data: "jane.doe@fixture.none", + meta: { + auditor: "super", + }, + }), + ); + + await store.pushEvent( + store.event({ + stream, + type: "user:deactivated", + meta: { + auditor: "super", + }, + }), + ); + + await store.createSnapshot({ name: "user", stream, reducer: userReducer }); + + const snapshot = await store.snapshots.getByStream("user", stream); + + assertNotEquals(snapshot, undefined); + assertObjectMatch(snapshot!.state, { + name: { + given: "John", + family: "Doe", + }, + email: "jane.doe@fixture.none", + active: false, + }); + + await store.pushEvent( + store.event({ + stream, + type: "user:activated", + meta: { + auditor: "super", + }, + }), + ); + + const events = await store.events.getByStream(stream, { cursor: snapshot!.cursor }); + + assertEquals(events.length, 1); + + const state = await store.reduce({ name: "user", stream, reducer: userReducer }); + + assertObjectMatch(state!, { + name: { + given: "John", + family: "Doe", + }, + email: "jane.doe@fixture.none", + active: true, + }); + }); +}); diff --git a/tests/adapters/store/make-aggregate-reducer.ts b/tests/adapters/store/make-aggregate-reducer.ts new file mode 100644 index 0000000..8a4e4e1 --- /dev/null +++ b/tests/adapters/store/make-aggregate-reducer.ts @@ -0,0 +1,25 @@ +import { assertEquals } from "@std/assert"; +import { it } from "@std/testing/bdd"; + +import type { EventStoreFactory } from "../mocks/events.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe(".makeAggregateReducer", (getEventStore) => { + it("should reduce a user", async () => { + const { store } = await getEventStore(); + + const userA = await store.aggregate("user").create({ given: "John", family: "Doe" }, "john.doe@fixture.none").setGivenName("Jane").save(); + + await userA.snapshot(); + + await userA.setFamilyName("Smith").setEmail("jane.smith@fixture.none", "system").save(); + + const userB = await store.aggregate("user").getById(userA.id); + if (userB === undefined) { + throw new Error("Expected user to exist"); + } + + assertEquals(userB.fullName(), "Jane Smith"); + assertEquals(userB.email, "jane.smith@fixture.none"); + }); +}); diff --git a/tests/adapters/store/make-event.ts b/tests/adapters/store/make-event.ts new file mode 100644 index 0000000..c241166 --- /dev/null +++ b/tests/adapters/store/make-event.ts @@ -0,0 +1,89 @@ +import { assertEquals, assertLess } from "@std/assert"; +import { it } from "@std/testing/bdd"; + +import { RelationPayload } from "../../../types/adapter.ts"; +import type { EventStoreFactory } from "../mocks/events.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe(".makeEvent", (getEventStore) => { + it("should make and performantly batch insert a list of events directly", async () => { + const { store } = await getEventStore(); + + const eventsToInsert = []; + + const t0 = performance.now(); + + let count = 10_000; + while (count--) { + eventsToInsert.push( + store.event({ + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "system", + }, + }), + ); + } + + const t1 = performance.now(); + + assertLess((t1 - t0) / 1000, 5); + + const t3 = performance.now(); + + await store.events.insertMany(eventsToInsert); + + const t4 = performance.now(); + + assertLess((t4 - t3) / 1000, 5); + + const events = await store.getEvents(); + + assertEquals(events.length, 10_000); + }); + + it("should performantly create and remove event relations", async () => { + const { store } = await getEventStore(); + + const relations: RelationPayload[] = []; + + let count = 10_000; + while (count--) { + const event = store.event({ + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "system", + }, + }); + relations.push({ key: `test:xyz`, stream: event.stream }); + } + + const t0 = performance.now(); + await store.relations.insertMany(relations); + const tr0 = (performance.now() - t0) / 1000; + + assertEquals((await store.relations.getByKey(`test:xyz`)).length, 10_000); + assertLess(tr0, 5); + + const t1 = performance.now(); + await store.relations.removeMany(relations); + const tr1 = (performance.now() - t1) / 1000; + + assertEquals((await store.relations.getByKey(`test:xyz`)).length, 0); + assertLess(tr1, 10); + }); +}); diff --git a/tests/adapters/store/make-reducer.ts b/tests/adapters/store/make-reducer.ts new file mode 100644 index 0000000..875fa86 --- /dev/null +++ b/tests/adapters/store/make-reducer.ts @@ -0,0 +1,120 @@ +import { assertEquals } from "@std/assert"; +import { it } from "@std/testing/bdd"; +import { nanoid } from "nanoid"; + +import { makeId } from "../../../libraries/nanoid.ts"; +import type { EventStoreFactory } from "../mocks/events.ts"; +import { userPostReducer } from "../mocks/user-posts-reducer.ts"; +import { userReducer } from "../mocks/user-reducer.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe(".makeReducer", (getEventStore) => { + it("should create a 'user' reducer and only reduce filtered events", async () => { + const { store } = await getEventStore(); + + const streamA = nanoid(); + const streamB = nanoid(); + + await store.pushEvent( + store.event({ + stream: streamA, + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "system", + }, + }), + ); + + await store.pushEvent( + store.event({ + stream: streamB, + type: "user:created", + data: { + name: { + given: "Peter", + family: "Parker", + }, + email: "peter.parker@fixture.none", + }, + meta: { + auditor: "system", + }, + }), + ); + + await store.pushEvent( + store.event({ + stream: streamA, + type: "user:name:given-set", + data: "Jane", + meta: { + auditor: "system", + }, + }), + ); + + await store.pushEvent( + store.event({ + stream: streamA, + type: "user:email-set", + data: "jane.doe@fixture.none", + meta: { + auditor: "system", + }, + }), + ); + + await store.pushEvent( + store.event({ + stream: streamB, + type: "user:email-set", + data: "spiderman@fixture.none", + meta: { + auditor: "system", + }, + }), + ); + + const state = await store.reduce({ name: "user", stream: streamA, reducer: userReducer, filter: { types: ["user:created", "user:email-set"] } }); + + assertEquals(state?.name, { given: "John", family: "Doe" }); + assertEquals(state?.email, "jane.doe@fixture.none"); + }); + + it("should create a 'post:count' reducer and retrieve post correct post count", async () => { + const { store, projector } = await getEventStore(); + const auditor = nanoid(); + + projector.on("post:created", async ({ stream, meta: { auditor } }) => { + await store.relations.insert(`user:${auditor}:posts`, stream); + }); + + const post1 = makeId(); + const post2 = makeId(); + const post3 = makeId(); + + await store.pushEvent(store.event({ stream: post1, type: "post:created", data: { title: "Post #1", body: "Sample #1" }, meta: { auditor } })); + await store.pushEvent(store.event({ stream: post2, type: "post:created", data: { title: "Post #2", body: "Sample #2" }, meta: { auditor } })); + await store.pushEvent(store.event({ stream: post2, type: "post:removed", meta: { auditor } })); + await store.pushEvent(store.event({ stream: post3, type: "post:created", data: { title: "Post #3", body: "Sample #3" }, meta: { auditor } })); + + const events = await store.getEventsByRelations([`user:${auditor}:posts`]); + + assertEquals(events.length, 4); + + const state = await store.reduce({ name: "user", relation: `user:${auditor}:posts`, reducer: userPostReducer }); + + assertEquals(state?.posts, [ + { id: post1, author: auditor }, + { id: post3, author: auditor }, + ]); + assertEquals(state?.count, 2); + }); +}); diff --git a/tests/adapters/store/once-projection.ts b/tests/adapters/store/once-projection.ts new file mode 100644 index 0000000..f64e7fe --- /dev/null +++ b/tests/adapters/store/once-projection.ts @@ -0,0 +1,94 @@ +import { assertEquals, assertObjectMatch } from "@std/assert"; +import { it } from "@std/testing/bdd"; + +import { makeId } from "../../../libraries/nanoid.ts"; +import type { EventStoreFactory } from "../mocks/events.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe("projector.once", (getEventStore) => { + it("should handle successfull projection", async () => { + const { store, projector } = await getEventStore(); + + const stream = makeId(); + const event = store.event({ + stream, + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "foo", + }, + }); + + let emailId: string | Error | undefined; + + projector.once( + "user:created", + async () => { + return { id: "fake-email-id" }; + }, + { + async onError({ error }) { + emailId = error as Error; + }, + async onSuccess({ data }) { + emailId = data.id; + }, + }, + ); + + await store.pushEvent(event); + + assertObjectMatch(await store.events.getByStream(stream).then((rows: any) => rows[0]), event); + assertEquals(emailId, "fake-email-id"); + }); + + it("should handle failed projection", async () => { + const { store, projector } = await getEventStore(); + + const stream = makeId(); + const event = store.event({ + stream, + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "foo", + }, + }); + + let emailId: string | undefined; + + projector.once( + "user:created", + async () => { + fakeEmail(); + }, + { + async onError({ error }) { + emailId = (error as Error).message; + }, + async onSuccess() {}, + }, + ); + + await store.pushEvent(event); + + assertObjectMatch(await store.events.getByStream(stream).then((rows: any) => rows[0]), event); + assertEquals(emailId, "Failed to send email!"); + }); +}); + +function fakeEmail() { + throw new Error("Failed to send email!"); +} diff --git a/tests/adapters/store/providers/relations.ts b/tests/adapters/store/providers/relations.ts new file mode 100644 index 0000000..4fe6f00 --- /dev/null +++ b/tests/adapters/store/providers/relations.ts @@ -0,0 +1,34 @@ +import { assertEquals } from "@std/assert"; +import { it } from "@std/testing/bdd"; +import { nanoid } from "nanoid"; + +import type { EventStoreFactory } from "../../mocks/events.ts"; +import { describe } from "../../utilities/describe.ts"; + +export default describe("relations", (getEventStore) => { + it("should create a new relation", async () => { + const { store } = await getEventStore(); + + const key = "sample"; + const stream = nanoid(); + + await store.relations.insert(key, stream); + + assertEquals(await store.relations.getByKey(key), [stream]); + }); + + it("should ignore duplicate relations", async () => { + const { store } = await getEventStore(); + + const key = "sample"; + const stream = nanoid(); + + await store.relations.insertMany([ + { key, stream }, + { key, stream }, + ]); + await store.relations.insert(key, stream); + + assertEquals(await store.relations.getByKey(key), [stream]); + }); +}); diff --git a/tests/adapters/store/push-aggregate.ts b/tests/adapters/store/push-aggregate.ts new file mode 100644 index 0000000..97044d0 --- /dev/null +++ b/tests/adapters/store/push-aggregate.ts @@ -0,0 +1,42 @@ +import { assertEquals, assertObjectMatch } from "@std/assert"; +import { it } from "@std/testing/bdd"; + +import type { EventStoreFactory } from "../mocks/events.ts"; +import { userReducer } from "../mocks/user-reducer.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe(".pushAggregate", (getEventStore) => { + it("should successfully commit pending aggregate events to the event store", async () => { + const { store } = await getEventStore(); + + const user = store + .aggregate("user") + .create({ given: "Jane", family: "Doe" }, "jane.doe@fixture.none") + .setGivenName("John") + .setEmail("john.doe@fixture.none", "admin"); + + assertEquals(user.toPending().length, 3); + + await store.pushAggregate(user); + + assertEquals(user.toPending().length, 0); + + const records = await store.getEventsByStreams([user.id]); + + assertEquals(records.length, 3); + + assertObjectMatch(records[0], { stream: user.id, data: { name: { given: "Jane", family: "Doe" }, email: "jane.doe@fixture.none" } }); + assertObjectMatch(records[1], { stream: user.id, data: "John" }); + assertObjectMatch(records[2], { stream: user.id, data: "john.doe@fixture.none", meta: { auditor: "admin" } }); + + const state = await store.reduce({ name: "user", stream: user.id, reducer: userReducer }); + + assertObjectMatch(state!, { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }); + }); +}); diff --git a/tests/adapters/store/push-many-aggregates.ts b/tests/adapters/store/push-many-aggregates.ts new file mode 100644 index 0000000..68c4cc7 --- /dev/null +++ b/tests/adapters/store/push-many-aggregates.ts @@ -0,0 +1,62 @@ +import { assertEquals, assertObjectMatch } from "@std/assert"; +import { it } from "@std/testing/bdd"; + +import type { EventStoreFactory } from "../mocks/events.ts"; +import { userReducer } from "../mocks/user-reducer.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe(".pushManyAggregates", (getEventStore) => { + it("should successfully commit pending aggregates events to the event store", async () => { + const { store } = await getEventStore(); + + const userA = store + .aggregate("user") + .create({ given: "Jane", family: "Doe" }, "jane.doe@fixture.none") + .setGivenName("John") + .setEmail("john.doe@fixture.none", "admin"); + + const userB = store + .aggregate("user") + .create({ given: "Peter", family: "Doe" }, "peter.doe@fixture.none") + .setGivenName("Barry") + .setEmail("barry.doe@fixture.none", "admin"); + + assertEquals(userA.toPending().length, 3); + assertEquals(userB.toPending().length, 3); + + await store.pushManyAggregates([userA, userB]); + + assertEquals(userA.toPending().length, 0); + assertEquals(userB.toPending().length, 0); + + const records = await store.getEventsByStreams([userA.id, userB.id]); + + assertEquals(records.length, 6); + + assertObjectMatch(records[0], { stream: userA.id, data: { name: { given: "Jane", family: "Doe" }, email: "jane.doe@fixture.none" } }); + assertObjectMatch(records[1], { stream: userA.id, data: "John" }); + assertObjectMatch(records[2], { stream: userA.id, data: "john.doe@fixture.none", meta: { auditor: "admin" } }); + assertObjectMatch(records[3], { stream: userB.id, data: { name: { given: "Peter", family: "Doe" }, email: "peter.doe@fixture.none" } }); + assertObjectMatch(records[4], { stream: userB.id, data: "Barry" }); + assertObjectMatch(records[5], { stream: userB.id, data: "barry.doe@fixture.none", meta: { auditor: "admin" } }); + + const stateA = await store.reduce({ name: "user", stream: userA.id, reducer: userReducer }); + const stateB = await store.reduce({ name: "user", stream: userB.id, reducer: userReducer }); + + assertObjectMatch(stateA!, { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }); + + assertObjectMatch(stateB!, { + name: { + given: "Barry", + family: "Doe", + }, + email: "barry.doe@fixture.none", + }); + }); +}); diff --git a/tests/adapters/store/reduce.ts b/tests/adapters/store/reduce.ts new file mode 100644 index 0000000..a7efc74 --- /dev/null +++ b/tests/adapters/store/reduce.ts @@ -0,0 +1,103 @@ +import { assertEquals } from "@std/assert"; +import { it } from "@std/testing/bdd"; +import { nanoid } from "nanoid"; + +import type { EventStoreFactory } from "../mocks/events.ts"; +import { userReducer } from "../mocks/user-reducer.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe(".reduce", (getEventStore) => { + it("should return reduced state", async () => { + const { store } = await getEventStore(); + const stream = nanoid(); + + await store.pushEvent( + store.event({ + stream, + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "super", + }, + }), + ); + + await store.pushEvent( + store.event({ + stream, + type: "user:email-set", + data: "jane.doe@fixture.none", + meta: { + auditor: "super", + }, + }), + ); + + const state = await store.reduce({ name: "user", stream, reducer: userReducer }); + + assertEquals(state, { + name: { given: "John", family: "Doe" }, + email: "jane.doe@fixture.none", + active: true, + posts: { list: [], count: 0 }, + }); + }); + + it("should return snapshot if it exists and no new events were found", async () => { + const { store } = await getEventStore(); + const stream = nanoid(); + + await store.pushEvent( + store.event({ + stream, + type: "user:created", + data: { + name: { + given: "John", + family: "Doe", + }, + email: "john.doe@fixture.none", + }, + meta: { + auditor: "super", + }, + }), + ); + + await store.pushEvent( + store.event({ + stream, + type: "user:email-set", + data: "jane.doe@fixture.none", + meta: { + auditor: "super", + }, + }), + ); + + await store.createSnapshot({ name: "user", stream, reducer: userReducer }); + + const state = await store.reduce({ name: "user", stream, reducer: userReducer }); + + assertEquals(state, { + name: { given: "John", family: "Doe" }, + email: "jane.doe@fixture.none", + active: true, + posts: { list: [], count: 0 }, + }); + }); + + it("should return undefined if stream does not have events", async () => { + const stream = nanoid(); + const { store } = await getEventStore(); + const state = await store.reduce({ name: "user", stream, reducer: userReducer }); + + assertEquals(state, undefined); + }); +}); diff --git a/tests/adapters/store/replay-events.ts b/tests/adapters/store/replay-events.ts new file mode 100644 index 0000000..39eec73 --- /dev/null +++ b/tests/adapters/store/replay-events.ts @@ -0,0 +1,94 @@ +import { assertObjectMatch } from "@std/assert"; +import { it } from "@std/testing/bdd"; +import { nanoid } from "nanoid"; + +import { EventStoreFactory } from "../mocks/events.ts"; +import { describe } from "../utilities/describe.ts"; + +export default describe(".replayEvents", (getEventStore) => { + it("should replay events", async () => { + const { store, projector } = await getEventStore(); + const stream = nanoid(); + + const record: Record = {}; + + projector.on("user:created", async ({ stream, data: { name, email } }) => { + record[stream] = { + name, + email, + }; + }); + + projector.on("user:name:given-set", async ({ stream, data }) => { + record[stream].name.given = data; + }); + + projector.on("user:email-set", async ({ stream, data }) => { + record[stream].email = data; + }); + + await store.pushManyEvents([ + store.event({ + stream, + type: "user:created", + data: { + name: { + given: "Jane", + family: "Doe", + }, + email: "jane.doe@fixture.none", + }, + meta: { + auditor: "admin", + }, + }), + store.event({ + stream, + type: "user:name:given-set", + data: "John", + meta: { + auditor: "admin", + }, + }), + store.event({ + stream, + type: "user:email-set", + data: "john@doe.com", + meta: { + auditor: "admin", + }, + }), + ]); + + assertObjectMatch(record, { + [stream]: { + name: { + given: "John", + family: "Doe", + }, + email: "john@doe.com", + }, + }); + + delete record[stream]; + + const promises = []; + + const records = await store.getEventsByStreams([stream]); + for (const record of records) { + promises.push(projector.push(record, { hydrated: true, outdated: false })); + } + + await Promise.all(promises); + + assertObjectMatch(record, { + [stream]: { + name: { + given: "John", + family: "Doe", + }, + email: "john@doe.com", + }, + }); + }); +}); diff --git a/tests/adapters/utilities/describe.ts b/tests/adapters/utilities/describe.ts new file mode 100644 index 0000000..8ee1926 --- /dev/null +++ b/tests/adapters/utilities/describe.ts @@ -0,0 +1,17 @@ +import { describe as desc } from "@std/testing/bdd"; + +import { EventFactory } from "../../../libraries/event-factory.ts"; +import { EventStore, type EventStoreHooks } from "../../../libraries/event-store.ts"; +import { Projector } from "../../../libraries/projector.ts"; + +export function describe( + name: string, + runner: (getEventStore: EventStoreFn) => void, +): (getEventStore: EventStoreFn) => void { + return (getEventStore: EventStoreFn) => desc(name, () => runner(getEventStore)); +} + +type EventStoreFn = (options?: { hooks?: EventStoreHooks }) => Promise<{ + store: EventStore; + projector: Projector; +}>; diff --git a/types/adapter.ts b/types/adapter.ts new file mode 100644 index 0000000..e3e121f --- /dev/null +++ b/types/adapter.ts @@ -0,0 +1,196 @@ +import type { EventRecord } from "../libraries/event.ts"; +import type { EventReadOptions } from "./query.ts"; + +export type EventStoreAdapter = { + readonly db: TDatabase; + readonly providers: { + readonly events: EventsProvider; + readonly relations: RelationsProvider; + readonly snapshots: SnapshotsProvider; + }; +}; + +/* + |-------------------------------------------------------------------------------- + | Events Provider + |-------------------------------------------------------------------------------- + */ + +export type EventsProvider = { + /** + * Insert a new event record to the events table. + * + * @param record - Event record to insert. + */ + insert(record: EventRecord): Promise; + + /** + * Insert many new event records to the events table. + * + * @param records - Event records to insert. + * @param batchSize - Batch size for the insert loop. Default: 1_000 + */ + insertMany(records: EventRecord[], batchSize?: number): Promise; + + /** + * Retrieve all the events in the events table. Optionally a cursor and direction + * can be provided to reduce the list of events returned. + * + * @param options - Find options. + */ + get(options?: EventReadOptions): Promise; + + /** + * Get events within the given stream. + * + * @param stream - Stream to fetch events for. + * @param options - Read options for modifying the result. + */ + getByStream(stream: string, options?: EventReadOptions): Promise; + + /** + * Get events within given list of streams. + * + * @param streams - Stream to get events for. + * @param options - Read options for modifying the result. + */ + getByStreams(streams: string[], options?: EventReadOptions): Promise; + + /** + * Get a single event by its id. + * + * @param id - Event id. + */ + getById(id: string): Promise; + + /** + * Check if the given event is outdated in relation to the local event data. + */ + checkOutdated({ stream, type, created }: EventRecord): Promise; +}; + +/* + |-------------------------------------------------------------------------------- + | Relations + |-------------------------------------------------------------------------------- + */ + +export type RelationsProvider = { + /** + * Handle incoming relation operations. + * + * @param relations - List of relation operations to execute. + */ + handle(relations: Relation[]): Promise; + + /** + * Add stream to the relations table. + * + * @param key - Relational key to add stream to. + * @param stream - Stream to add to the key. + */ + insert(key: string, stream: string): Promise; + + /** + * Add stream to many relational keys onto the relations table. + * + * @param relations - Relations to insert. + * @param batchSize - Batch size for the insert loop. Default: 1_000 + */ + insertMany(relations: RelationPayload[], batchSize?: number): Promise; + + /** + * Get a list of event streams registered under the given relational key. + * + * @param key - Relational key to get event streams for. + */ + getByKey(key: string): Promise; + + /** + * Get a list of event streams registered under the given relational keys. + * + * @param keys - Relational keys to get event streams for. + */ + getByKeys(keys: string[]): Promise; + + /** + * Removes a stream from the relational table. + * + * @param key - Relational key to remove stream from. + * @param stream - Stream to remove from relation. + */ + remove(key: string, stream: string): Promise; + + /** + * Removes multiple relational entries. + * + * @param relations - Relations to remove stream from. + * @param batchSize - Batch size for the insert loop. Default: 1_000 + */ + removeMany(relations: RelationPayload[], batchSize?: number): Promise; + + /** + * Remove all relations bound to the given relational keys. + * + * @param keys - Relational keys to remove from the relational table. + */ + removeByKeys(keys: string[]): Promise; + + /** + * Remove all relations bound to the given streams. + * + * @param streams - Streams to remove from the relational table. + */ + removeByStreams(streams: string[]): Promise; +}; + +export type RelationHandler = (record: TRecord) => Promise[]>; + +export type RelationPayload = Omit; + +export type Relation = { + op: "insert" | "remove"; + key: string; + stream: string; +}; + +/* + |-------------------------------------------------------------------------------- + | Snapshots + |-------------------------------------------------------------------------------- + */ + +export type SnapshotsProvider = { + /** + * Add snapshot state under given reducer stream. + * + * @param name - Name of the reducer the snapshot is attached to. + * @param stream - Stream the snapshot is attached to. + * @param cursor - Cursor timestamp for the last event used in the snapshot. + * @param state - State of the reduced events. + */ + insert(name: string, stream: string, cursor: string, state: Record): Promise; + + /** + * Get snapshot state by stream. + * + * @param name - Name of the reducer which the state was created. + * @param stream - Stream the state was reduced for. + */ + getByStream(name: string, stream: string): Promise; + + /** + * Removes a snapshot for the given reducer stream. + * + * @param name - Name of the reducer the snapshot is attached to. + * @param stream - Stream to remove from snapshots. + */ + remove(name: string, stream: string): Promise; +}; + +export type Snapshot = { + stream: string; + name: string; + cursor: string; + state: Record; +}; diff --git a/types/common.ts b/types/common.ts new file mode 100644 index 0000000..6158658 --- /dev/null +++ b/types/common.ts @@ -0,0 +1,32 @@ +/** + * Represents an empty object. + */ +export type Empty = Record; + +/** + * Represent an unknown object. + */ +export type Unknown = Record; + +/** + * Represents a subscription that exposes a way to unsubscribe. + * + * @example + * + * ```ts + * function subscribe(): Subscription { + * const interval = setInterval(() => console.log("foo"), 1000); + * return { + * unsubscribe() { + * clearInterval(interval); + * } + * } + * } + * ``` + */ +export type Subscription = { + /** + * Gracefully terminate a decoupled subscriber. + */ + unsubscribe: () => void; +}; diff --git a/types/projector.ts b/types/projector.ts new file mode 100644 index 0000000..273d2c8 --- /dev/null +++ b/types/projector.ts @@ -0,0 +1,50 @@ +import type { EventRecord } from "../libraries/event.ts"; + +export type BatchedProjectorListeners = Record> | undefined>; + +export type ProjectorListeners = Record> | undefined>; + +export type ProjectorMessage = { + record: TRecord; + status: ProjectionStatus; +}; + +export type BatchedProjectorListenerFn = (records: TRecord[]) => void; + +export type ProjectorListenerFn = (record: TRecord, status: ProjectionStatus) => void; + +export type ProjectionHandler | void = void> = TSuccessData extends void + ? (record: TRecord) => Promise + : (record: TRecord) => Promise; + +export type BatchedProjectionHandler = (records: TRecord[]) => Promise; + +export type ProjectionStatus = { + /** + * Has the event run through projections previously. In which case we do + * not want to re-run one time projections that should not execute during + * replay events. + */ + hydrated: boolean; + + /** + * Is the incoming event older than another event of the same type in + * the same stream. + */ + outdated: boolean; +}; + +export type ProjectionFilter = { + /** + * Hydrated events represents events that are not seen for the first time + * in the entirety of its lifetime across all distributed instances. + */ + allowHydratedEvents: boolean; + + /** + * Outdated events represents events that have already seen the same type + * at a later occurrence. Eg. If incoming event is older than the latest + * local event of the same type, it is considered outdated. + */ + allowOutdatedEvents: boolean; +}; diff --git a/types/query.ts b/types/query.ts new file mode 100644 index 0000000..a10c059 --- /dev/null +++ b/types/query.ts @@ -0,0 +1,72 @@ +import type { Reducer } from "../libraries/reducer.ts"; + +export type ReduceQuery = + | ({ + /** + * Name of the reducer, must be a unique identifier as its used by snapshotter + * to store, and manage state snapshots for event streams. + */ + name: string; + + /** + * Stream to fetch events from and pass to the reducer method. + */ + stream: string; + + /** + * Reducer method to pass resolved events to. + */ + reducer: TReducer; + + relation?: never; + } & EventReadFilter) + | ({ + /** + * Name of the reducer, must be a unique identifier as its used by snapshotter + * to store, and manage state snapshots for event streams. + */ + name: string; + + /** + * Relational key resolving streams to fetch events from and pass to the + * reducer method. + */ + relation: string; + + /** + * Reducer method to pass resolved events to. + */ + reducer: TReducer; + + stream?: never; + } & EventReadFilter); + +export type EventReadOptions = EventReadFilter & { + /** + * Fetches events from the specific cursor, which uses the local event + * records `recorded` timestamp. + */ + cursor?: string; + + /** + * Fetch events in ascending or descending order. Default: "asc" + */ + direction?: 1 | -1 | "asc" | "desc"; + + /** + * Limit the number of events returned. + */ + limit?: number; +}; + +export type EventReadFilter = { + /** + * Filter options for how events are pulled from the store. + */ + filter?: { + /** + * Only include events in the given types. + */ + types?: string[]; + }; +}; diff --git a/types/utilities.ts b/types/utilities.ts new file mode 100644 index 0000000..fec478c --- /dev/null +++ b/types/utilities.ts @@ -0,0 +1,5 @@ +import type { Empty } from "./common.ts"; + +export type ExcludeEmptyFields = { + [K in keyof T as T[K] extends Empty ? never : K]: T[K]; +};