feat: version 2 beta

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

View File

@@ -0,0 +1,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<const TEvent extends Event> implements EventStoreAdapter<EventStoreDB> {
readonly #database: IndexedDatabase<Collections>;
providers: EventStoreAdapter<TEvent>["providers"];
constructor(database: Adapter, name = "valkyr:event-store") {
this.#database = getEventStoreDatabase(name, database) as IndexedDatabase<Collections>;
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<Collections> {
return this.#database;
}
}

View File

@@ -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<Collections>({
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<Collections>({
name,
registrars: [{ name: "events" }, { name: "relations" }, { name: "snapshots" }],
});
}
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type EventStoreDB = IndexedDatabase<Collections> | MemoryDatabase<Collections>;
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<string, unknown>;
};

View File

@@ -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<EventRecord>) {}
/**
* 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<void> {
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<void> {
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<EventRecord[]> {
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<EventRecord[]> {
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<EventRecord[]> {
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<EventRecord | undefined> {
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<boolean> {
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,
};
}
}

View File

@@ -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<Relation>) {}
/**
* Handle incoming relation operations.
*
* @param relations - List of relation operations to execute.
*/
async handle(relations: Relation[]): Promise<void> {
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<void> {
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<void> {
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<string[]> {
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<string[]> {
return this.relations.find({ key: { $in: keys } }).then((relations) => {
const streamIds = new Set<string>();
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<void> {
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<void> {
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<void> {
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<void> {
await this.relations.remove({ stream: { $in: streams } });
}
}

View File

@@ -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<Snapshot>) {}
/**
* 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<string, unknown>): Promise<void> {
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<Snapshot | undefined> {
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<void> {
await this.snapshots.remove({ name, stream });
}
}