Compare commits

..

6 Commits

12 changed files with 180 additions and 30 deletions

View File

@ -12,9 +12,7 @@
"deno.enable": true,
"editor.defaultFormatter": "denoland.vscode-deno",
"deno.future": true,
"deno.codeLens.implementations": true,
"deno.codeLens.references": true,
"typescript.referencesCodeLens.enabled": true,
"deno.testing.args": [
"--allow-all"
],

View File

@ -7,10 +7,10 @@ import type {
VariantTag,
} from "@fabric/core";
import type { StoreQueryError } from "../errors/query-error.ts";
import type { Event } from "./event.ts";
import type { DomainEvent } from "./event.ts";
import type { StoredEvent } from "./stored-event.ts";
export interface EventStore<TEvents extends Event> {
export interface EventStore<TEvents extends DomainEvent> {
/**
* Store a new event in the event store.
*/
@ -28,7 +28,7 @@ export interface EventStore<TEvents extends Event> {
): void;
}
export type EventSubscriber<TEvents extends Event = Event> = (
export type EventSubscriber<TEvents extends DomainEvent = DomainEvent> = (
event: StoredEvent<TEvents>,
) => MaybePromise<void>;

View File

@ -1,18 +1,18 @@
// deno-lint-ignore-file no-explicit-any
import type { VariantTag } from "@fabric/core";
import type { TaggedVariant, VariantTag } from "@fabric/core";
import type { UUID } from "../../core/types/uuid.ts";
/**
* An event is a tagged variant with a payload and a timestamp.
*/
export interface Event<TTag extends string = string, TPayload = any> {
readonly [VariantTag]: TTag;
export interface DomainEvent<TTag extends string = string, TPayload = any>
extends TaggedVariant<TTag> {
readonly id: UUID;
readonly streamId: UUID;
readonly payload: TPayload;
}
export type EventFromKey<
TEvents extends Event,
TEvents extends DomainEvent,
TKey extends TEvents[VariantTag],
> = Extract<TEvents, { [VariantTag]: TKey }>;

View File

@ -1,10 +1,10 @@
import type { PosixDate } from "@fabric/core";
import type { Event } from "./event.ts";
import type { DomainEvent } from "./event.ts";
/**
* A stored event is an inmutable event, already stored, with it's version in the stream and timestamp.
*/
export type StoredEvent<TEvent extends Event> = TEvent & {
export type StoredEvent<TEvent extends DomainEvent> = TEvent & {
readonly version: bigint;
readonly timestamp: PosixDate;
};

View File

@ -28,6 +28,8 @@ export interface StoreQuery<T> {
selectOneOrFail<K extends Keyof<T>>(
keys: K[],
): AsyncResult<Pick<T, K>, StoreQueryError | NotFoundError>;
assertNone(): AsyncResult<void, StoreQueryError | AlreadyExistsError>;
}
export interface StoreSortableQuery<T> {
@ -48,6 +50,8 @@ export interface StoreSortableQuery<T> {
selectOneOrFail<K extends Keyof<T>>(
keys: K[],
): AsyncResult<Pick<T, K>, StoreQueryError | NotFoundError>;
assertNone(): AsyncResult<void, StoreQueryError | AlreadyExistsError>;
}
export interface StoreLimitableQuery<T> {
@ -67,6 +71,8 @@ export interface StoreLimitableQuery<T> {
selectOneOrFail<K extends Keyof<T>>(
keys: K[],
): AsyncResult<Pick<T, K>, StoreQueryError | NotFoundError>;
assertNone(): AsyncResult<void, StoreQueryError | AlreadyExistsError>;
}
export interface SelectableQuery<T> {
@ -84,6 +90,8 @@ export interface SelectableQuery<T> {
selectOneOrFail<K extends Keyof<T>>(
keys: K[],
): AsyncResult<Pick<T, K>, StoreQueryError | NotFoundError>;
assertNone(): AsyncResult<void, StoreQueryError | AlreadyExistsError>;
}
export interface StoreQueryDefinition<K extends string = string> {
@ -100,3 +108,9 @@ export class NotFoundError extends TaggedError<"NotFoundError"> {
super("NotFoundError");
}
}
export class AlreadyExistsError extends TaggedError<"AlreadyExistsError"> {
constructor() {
super("AlreadyExistsError");
}
}

View File

@ -1,11 +1,11 @@
import type { VariantTag } from "@fabric/core";
import type { Event } from "../events/event.ts";
import type { DomainEvent } from "../events/event.ts";
import type { StoredEvent } from "../events/stored-event.ts";
import type { AggregateModel, ModelToType } from "../models/model.ts";
export interface Projection<
TModel extends AggregateModel,
TEvents extends Event,
TEvents extends DomainEvent,
> {
model: TModel;
events: TEvents[VariantTag][];

View File

@ -1,18 +1,24 @@
// deno-lint-ignore-file no-explicit-any
import type { TaggedError } from "@fabric/core";
import type { DomainEvent } from "../events/event.ts";
import type { UseCase } from "./use-case.ts";
export type Command<
TDependencies = any,
TPayload = any,
TEvent extends Event = any,
TEvent extends DomainEvent = any,
TErrors extends TaggedError<string> = any,
> = BasicCommandDefinition<TDependencies, TPayload, TEvent, TErrors>;
> = BasicCommandDefinition<
TDependencies,
TPayload,
TEvent,
TErrors
>;
interface BasicCommandDefinition<
TDependencies,
TPayload,
TEvent extends Event,
TEvent extends DomainEvent,
TErrors extends TaggedError<string>,
> {
/**

View File

@ -7,7 +7,12 @@ export type Query<
TPayload = any,
TOutput = any,
TErrors extends TaggedError<string> = any,
> = BasicQueryDefinition<TDependencies, TPayload, TOutput, TErrors>;
> = BasicQueryDefinition<
TDependencies,
TPayload,
TOutput,
TErrors
>;
interface BasicQueryDefinition<
TDependencies,

View File

@ -1,5 +1,5 @@
import { PosixDate } from "@fabric/core";
import { Event } from "@fabric/domain";
import { DomainEvent } from "@fabric/domain";
import { UUIDGeneratorMock } from "@fabric/domain/mocks";
import {
afterEach,
@ -12,9 +12,9 @@ import {
import { SQLiteEventStore } from "./event-store.ts";
describe("Event Store", () => {
type UserCreated = Event<"UserCreated", { name: string }>;
type UserUpdated = Event<"UserUpdated", { name: string }>;
type UserDeleted = Event<"UserDeleted", void>;
type UserCreated = DomainEvent<"UserCreated", { name: string }>;
type UserUpdated = DomainEvent<"UserUpdated", { name: string }>;
type UserDeleted = DomainEvent<"UserDeleted", void>;
type UserEvents = UserCreated | UserUpdated | UserDeleted;

View File

@ -8,7 +8,7 @@ import {
VariantTag,
} from "@fabric/core";
import {
Event,
DomainEvent,
EventFromKey,
EventStore,
EventSubscriber,
@ -17,7 +17,7 @@ import {
} from "@fabric/domain";
import { SQLiteDatabase } from "../sqlite/sqlite-database.ts";
export class SQLiteEventStore<TEvents extends Event>
export class SQLiteEventStore<TEvents extends DomainEvent>
implements EventStore<TEvents> {
private db: SQLiteDatabase;
@ -139,7 +139,7 @@ export class SQLiteEventStore<TEvents extends Event>
);
}
private storeEvent<T extends Event>(
private storeEvent<T extends DomainEvent>(
streamId: UUID,
version: bigint,
event: T,

View File

@ -0,0 +1,101 @@
import {
AlreadyExistsError,
Field,
isLike,
Model,
NotFoundError,
} from "@fabric/domain";
import { UUIDGeneratorMock } from "@fabric/domain/mocks";
import { afterEach, beforeEach, describe, expect, test } from "@fabric/testing";
import { SQLiteStateStore } from "./state-store.ts";
describe("QueryBuilder", () => {
const models = [
Model.entityFrom("test", {
name: Field.string(),
}),
];
let stateStore = new SQLiteStateStore(":memory:", models);
beforeEach(async () => {
stateStore = new SQLiteStateStore(":memory:", models);
await stateStore.migrate().unwrapOrThrow();
await stateStore.insertInto("test", {
id: UUIDGeneratorMock.generate(),
name: "test1",
}).unwrapOrThrow();
await stateStore.insertInto("test", {
id: UUIDGeneratorMock.generate(),
name: "test2",
}).unwrapOrThrow();
});
afterEach(async () => {
await stateStore.close().unwrapOrThrow();
});
test("select() after a where() should return valid results", async () => {
const result = await stateStore.from("test").where({
name: isLike("test%"),
})
.select().unwrapOrThrow();
expect(result).toEqual([{
id: expect.any(String),
name: "test1",
}, {
id: expect.any(String),
name: "test2",
}]);
});
test("selectOneOrFail() should return a single result", async () => {
const result = await stateStore.from("test").where({ name: "test1" })
.selectOneOrFail().unwrapOrThrow();
expect(result).toEqual({
id: expect.any(String),
name: "test1",
});
});
test("selectOneOrFail() should fail if no results are found", async () => {
const error = await stateStore.from("test").where({ name: "not-found" })
.selectOneOrFail().unwrapErrorOrThrow();
expect(error).toBeInstanceOf(NotFoundError);
});
test("selectOne() should return a single result", async () => {
const result = await stateStore.from("test")
.selectOne().unwrapOrThrow();
expect(result).toEqual({
id: expect.any(String),
name: "test1",
});
});
test("selectOne() should return undefined if no results are found", async () => {
const result = await stateStore.from("test").where({
name: "not-found",
})
.selectOne().unwrapOrThrow();
expect(result).toBeUndefined();
});
test("assertNone() should succeed if no results are found", async () => {
const result = await stateStore.from("test").where({
name: "not-found",
}).assertNone().unwrapOrThrow();
expect(result).toBeUndefined();
});
test("assertNone() should fail if results are found", async () => {
const error = await stateStore.from("test").where({ name: "test1" })
.assertNone().unwrapErrorOrThrow();
expect(error).toBeInstanceOf(AlreadyExistsError);
});
});

View File

@ -1,6 +1,7 @@
// deno-lint-ignore-file no-explicit-any
import { AsyncResult, Keyof, Optional } from "@fabric/core";
import {
AlreadyExistsError,
FilterOptions,
Model,
type ModelSchema,
@ -112,18 +113,43 @@ export class QueryBuilder<T> implements StoreQuery<T> {
limit: 1,
},
);
const result = await this.db.onePrepared(
return await this.db.onePrepared(
stmt,
params,
transformRow(this.schema[this.query.from]!),
);
if (!result) {
throw new NotFoundError();
}
return result;
},
(err) => new StoreQueryError(err.message),
).flatMap((result) => {
if (!result) {
return AsyncResult.failWith(new NotFoundError());
}
return AsyncResult.ok(result);
});
}
assertNone(): AsyncResult<void, StoreQueryError | AlreadyExistsError> {
return AsyncResult.tryFrom(
async () => {
const [stmt, params] = getSelectStatement(
this.schema[this.query.from]!,
{
...this.query,
limit: 1,
},
);
return await this.db.onePrepared(
stmt,
params,
);
},
(err) => new StoreQueryError(err.message),
).flatMap((result) => {
if (result) {
return AsyncResult.failWith(new AlreadyExistsError());
}
return AsyncResult.ok();
});
}
}