[fabric/sqlite-store] Implement SQLiteEventStore
This commit is contained in:
parent
a6a303f256
commit
4ea00f515b
77
packages/fabric/sqlite-store/src/events/event-store.spec.ts
Normal file
77
packages/fabric/sqlite-store/src/events/event-store.spec.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
176
packages/fabric/sqlite-store/src/events/event-store.ts
Normal file
176
packages/fabric/sqlite-store/src/events/event-store.ts
Normal 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 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/fabric/sqlite-store/src/events/index.ts
Normal file
1
packages/fabric/sqlite-store/src/events/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./event-store.js";
|
||||||
@ -79,7 +79,11 @@ export class SQLiteDatabase {
|
|||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
|
try {
|
||||||
resolve(transformer ? rows.map(transformer) : rows);
|
resolve(transformer ? rows.map(transformer) : rows);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -100,7 +104,11 @@ export class SQLiteDatabase {
|
|||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
|
try {
|
||||||
resolve(transformer ? rows.map(transformer)[0] : rows[0]);
|
resolve(transformer ? rows.map(transformer)[0] : rows[0]);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -14,7 +14,7 @@ import {
|
|||||||
} from "@fabric/domain";
|
} from "@fabric/domain";
|
||||||
import { filterToParams, filterToSQL } from "../sqlite/filter-to-sql.js";
|
import { filterToParams, filterToSQL } from "../sqlite/filter-to-sql.js";
|
||||||
import { transformRow } from "../sqlite/sql-to-value.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> {
|
export class QueryBuilder<T> implements StoreQuery<T> {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
recordToSQLParams,
|
recordToSQLParams,
|
||||||
recordToSQLSet,
|
recordToSQLSet,
|
||||||
} from "../sqlite/record-utils.js";
|
} 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";
|
import { QueryBuilder } from "./query-builder.js";
|
||||||
|
|
||||||
export class SQLiteStateStore<TModel extends Model>
|
export class SQLiteStateStore<TModel extends Model>
|
||||||
@ -26,7 +26,7 @@ export class SQLiteStateStore<TModel extends Model>
|
|||||||
private db: SQLiteDatabase;
|
private db: SQLiteDatabase;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private dbPath: string,
|
private readonly dbPath: string,
|
||||||
models: TModel[],
|
models: TModel[],
|
||||||
) {
|
) {
|
||||||
this.schema = models.reduce((acc, model: TModel) => {
|
this.schema = models.reduce((acc, model: TModel) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user