Feature: Basic Events, Models and Projections #2

Merged
piarrot merged 37 commits from feat-base-projections into main 2024-10-15 15:20:25 -03:00
6 changed files with 267 additions and 5 deletions
Showing only changes of commit 4ea00f515b - Show all commits

View File

@ -0,0 +1,77 @@
import { PosixDate, Run } from "@fabric/core";
import { Event } from "@fabric/domain";
import { UUIDGeneratorMock } from "@fabric/domain/mocks";
import { afterEach, beforeEach, describe, expect, it, vitest } from "vitest";
import { SQLiteEventStore } from "./event-store.js";
describe("Event Store", () => {
type UserCreated = Event<"UserCreated", { name: string }>;
type UserUpdated = Event<"UserUpdated", { name: string }>;
type UserDeleted = Event<"UserDeleted", void>;
type UserEvents = UserCreated | UserUpdated | UserDeleted;
let store: SQLiteEventStore<UserEvents>;
beforeEach(async () => {
store = new SQLiteEventStore(":memory:");
await Run.UNSAFE(() => store.migrate());
});
afterEach(async () => {
await Run.UNSAFE(() => store.close());
});
it("Should append an event", async () => {
const newUUID = UUIDGeneratorMock.generate();
const userCreated: UserCreated = {
type: "UserCreated",
id: newUUID,
streamId: newUUID,
payload: { name: "test" },
};
await Run.UNSAFE(() => store.append(userCreated));
const events = await Run.UNSAFE(() => store.getEventsFromStream(newUUID));
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
id: newUUID,
streamId: newUUID,
type: "UserCreated",
version: BigInt(1),
timestamp: expect.any(PosixDate),
payload: { name: "test" },
});
});
it("should notify subscribers on append", async () => {
const newUUID = UUIDGeneratorMock.generate();
const userCreated: UserCreated = {
type: "UserCreated",
id: newUUID,
streamId: newUUID,
payload: { name: "test" },
};
const subscriber = vitest.fn();
store.subscribe(["UserCreated"], subscriber);
await Run.UNSAFE(() => store.append(userCreated));
expect(subscriber).toHaveBeenCalledTimes(1);
expect(subscriber).toHaveBeenCalledWith({
id: newUUID,
streamId: newUUID,
type: "UserCreated",
version: BigInt(1),
timestamp: expect.any(PosixDate),
payload: { name: "test" },
});
});
});

View File

@ -0,0 +1,176 @@
import { AsyncResult, MaybePromise, PosixDate, Run } from "@fabric/core";
import {
Event,
EventFromKey,
EventStore,
EventSubscriber,
JSONUtils,
StoredEvent,
StoreQueryError,
UUID,
} from "@fabric/domain";
import { SQLiteDatabase } from "../sqlite/sqlite-database.js";
export class SQLiteEventStore<TEvents extends Event>
implements EventStore<TEvents>
{
private db: SQLiteDatabase;
private streamVersions = new Map<UUID, bigint>();
private eventSubscribers = new Map<
TEvents["type"],
EventSubscriber<TEvents>[]
>();
constructor(private readonly dbPath: string) {
this.db = new SQLiteDatabase(dbPath);
}
async migrate(): AsyncResult<void, StoreQueryError> {
return AsyncResult.tryFrom(
async () => {
await this.db.init();
await this.db.run(
`CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
streamId TEXT NOT NULL,
version INTEGER NOT NULL,
timestamp NUMERIC NOT NULL,
payload TEXT NOT NULL,
UNIQUE(streamId, version)
)`,
);
},
(error) => new StoreQueryError(error.message, { error }),
);
}
async getEventsFromStream(
streamId: UUID,
): AsyncResult<StoredEvent<TEvents>[], StoreQueryError> {
return AsyncResult.tryFrom(
async () => {
const events = await this.db.allPrepared(
`SELECT * FROM events WHERE streamId = $id`,
{
$id: streamId,
},
(event) => ({
id: event.id,
streamId: event.streamId,
type: event.type,
version: BigInt(event.version),
timestamp: new PosixDate(event.timestamp),
payload: JSONUtils.parse(event.payload),
}),
);
return events;
},
(error) => new StoreQueryError(error.message, { error }),
);
}
async append<T extends TEvents>(
event: T,
): AsyncResult<StoredEvent<T>, StoreQueryError> {
return Run.seq(
() => this.getLastVersion(event.streamId),
(version) =>
AsyncResult.from(() => {
this.streamVersions.set(event.streamId, version + 1n);
return version;
}),
(version) => this.storeEvent(event.streamId, version + 1n, event),
(storedEvent) =>
AsyncResult.from(async () => {
await this.notifySubscribers(storedEvent);
return storedEvent;
}),
);
}
private async notifySubscribers(
event: StoredEvent<TEvents>,
): AsyncResult<void> {
return AsyncResult.from(async () => {
const subscribers = this.eventSubscribers.get(event.type) || [];
await Promise.all(subscribers.map((subscriber) => subscriber(event)));
});
}
private async getLastVersion(
streamId: UUID,
): AsyncResult<bigint, StoreQueryError> {
return AsyncResult.tryFrom(
async () => {
const { lastVersion } = await this.db.onePrepared(
`SELECT max(version) as lastVersion FROM events WHERE streamId = $id`,
{
$id: streamId,
},
);
return !lastVersion ? 0n : BigInt(lastVersion);
},
(error) =>
new StoreQueryError(`Error getting last version:${error.message}`, {
error,
}),
);
}
subscribe<TEventKey extends TEvents["type"]>(
events: TEventKey[],
subscriber: (
event: StoredEvent<EventFromKey<TEvents, TEventKey>>,
) => MaybePromise<void>,
): void {
events.forEach((event) => {
const subscribers = this.eventSubscribers.get(event) || [];
const newSubscribers = [
...subscribers,
subscriber,
] as EventSubscriber<TEvents>[];
this.eventSubscribers.set(event, newSubscribers);
});
}
close(): AsyncResult<void, StoreQueryError> {
return AsyncResult.tryFrom(
() => this.db.close(),
(error) => new StoreQueryError(error.message, { error }),
);
}
private storeEvent<T extends Event>(
streamId: UUID,
version: bigint,
event: T,
): AsyncResult<StoredEvent<T>, StoreQueryError> {
return AsyncResult.tryFrom(
async () => {
const storedEvent: StoredEvent<T> = {
...event,
version: version,
timestamp: new PosixDate(),
};
await this.db.runPrepared(
`INSERT INTO events (id, streamId, type, version, timestamp, payload)
VALUES ($id, $streamId, $type, $version, $timestamp, $payload)`,
{
$id: storedEvent.id,
$streamId: streamId,
$type: storedEvent.type,
$version: storedEvent.version.toString(),
$timestamp: storedEvent.timestamp.timestamp,
$payload: JSON.stringify(storedEvent.payload),
},
);
return storedEvent;
},
(error) => new StoreQueryError("Error appending event", { error }),
);
}
}

View File

@ -0,0 +1 @@
export * from "./event-store.js";

View File

@ -79,7 +79,11 @@ export class SQLiteDatabase {
if (err) {
reject(err);
} else {
try {
resolve(transformer ? rows.map(transformer) : rows);
} catch (e) {
reject(e);
}
}
},
);
@ -100,7 +104,11 @@ export class SQLiteDatabase {
if (err) {
reject(err);
} else {
try {
resolve(transformer ? rows.map(transformer)[0] : rows[0]);
} catch (e) {
reject(e);
}
}
},
);

View File

@ -14,7 +14,7 @@ import {
} from "@fabric/domain";
import { filterToParams, filterToSQL } from "../sqlite/filter-to-sql.js";
import { transformRow } from "../sqlite/sql-to-value.js";
import { SQLiteDatabase } from "../sqlite/sqlite-wrapper.js";
import { SQLiteDatabase } from "../sqlite/sqlite-database.js";
export class QueryBuilder<T> implements StoreQuery<T> {
constructor(

View File

@ -16,7 +16,7 @@ import {
recordToSQLParams,
recordToSQLSet,
} from "../sqlite/record-utils.js";
import { SQLiteDatabase } from "../sqlite/sqlite-wrapper.js";
import { SQLiteDatabase } from "../sqlite/sqlite-database.js";
import { QueryBuilder } from "./query-builder.js";
export class SQLiteStateStore<TModel extends Model>
@ -26,7 +26,7 @@ export class SQLiteStateStore<TModel extends Model>
private db: SQLiteDatabase;
constructor(
private dbPath: string,
private readonly dbPath: string,
models: TModel[],
) {
this.schema = models.reduce((acc, model: TModel) => {