feat: version 2 beta
This commit is contained in:
81
tests/adapters/browser-iddb.test.ts
Normal file
81
tests/adapters/browser-iddb.test.ts
Normal file
@@ -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<EventStoreFactory> } = {}) => 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<EventStoreFactory> }) {
|
||||
const store = new EventStore({
|
||||
adapter: new BrowserAdapter("indexeddb"),
|
||||
events,
|
||||
aggregates,
|
||||
hooks,
|
||||
});
|
||||
|
||||
const projector = new Projector<EventStoreFactory>();
|
||||
|
||||
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 };
|
||||
}
|
||||
70
tests/adapters/browser-memory.test.ts
Normal file
70
tests/adapters/browser-memory.test.ts
Normal file
@@ -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<EventStoreFactory> } = {}) => 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<EventStoreFactory> }) {
|
||||
const store = new EventStore({
|
||||
adapter: new BrowserAdapter("memorydb"),
|
||||
events,
|
||||
aggregates,
|
||||
hooks,
|
||||
});
|
||||
|
||||
const projector = new Projector<EventStoreFactory>();
|
||||
|
||||
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 };
|
||||
}
|
||||
134
tests/adapters/mocks/aggregates.ts
Normal file
134
tests/adapters/mocks/aggregates.ts
Normal file
@@ -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<EventStoreFactory> {
|
||||
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<User | undefined> {
|
||||
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<this> {
|
||||
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;
|
||||
};
|
||||
19
tests/adapters/mocks/errors.ts
Normal file
19
tests/adapters/mocks/errors.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export abstract class ServiceError<TData = unknown> 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<TData = unknown> extends ServiceError<TData> {
|
||||
constructor(message = "Custom Error", data?: TData) {
|
||||
super(message, 400, data);
|
||||
}
|
||||
}
|
||||
32
tests/adapters/mocks/events.ts
Normal file
32
tests/adapters/mocks/events.ts
Normal file
@@ -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;
|
||||
32
tests/adapters/mocks/user-posts-reducer.ts
Normal file
32
tests/adapters/mocks/user-posts-reducer.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { makeReducer } from "../../../libraries/reducer.ts";
|
||||
import { EventStoreFactory } from "./events.ts";
|
||||
|
||||
export const userPostReducer = makeReducer<EventStoreFactory, UserPostState>(
|
||||
(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;
|
||||
};
|
||||
61
tests/adapters/mocks/user-reducer.ts
Normal file
61
tests/adapters/mocks/user-reducer.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { makeReducer } from "../../../libraries/reducer.ts";
|
||||
import { EventStoreFactory } from "./events.ts";
|
||||
|
||||
export const userReducer = makeReducer<EventStoreFactory, UserState>(
|
||||
(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;
|
||||
};
|
||||
};
|
||||
99
tests/adapters/mongodb.test.ts
Normal file
99
tests/adapters/mongodb.test.ts
Normal file
@@ -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<EventStoreFactory> } = {}) => 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<EventStoreFactory> }) {
|
||||
const store = new EventStore({
|
||||
adapter: new MongoAdapter(() => container.client, DB_NAME),
|
||||
events,
|
||||
aggregates,
|
||||
hooks,
|
||||
});
|
||||
|
||||
const projector = new Projector<EventStoreFactory>();
|
||||
|
||||
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 };
|
||||
}
|
||||
137
tests/adapters/postgres.test.ts
Normal file
137
tests/adapters/postgres.test.ts
Normal file
@@ -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<EventStoreFactory> } = {}) => 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<EventStoreFactory> }) {
|
||||
const store = new EventStore({
|
||||
adapter: new PostgresAdapter(connection, { schema: "event_store" }),
|
||||
events,
|
||||
aggregates,
|
||||
hooks,
|
||||
});
|
||||
|
||||
const projector = new Projector<EventStoreFactory>();
|
||||
|
||||
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 };
|
||||
}
|
||||
220
tests/adapters/store/add-event.ts
Normal file
220
tests/adapters/store/add-event.ts
Normal file
@@ -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<EventStoreFactory>(".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);
|
||||
});
|
||||
});
|
||||
108
tests/adapters/store/add-many-events.ts
Normal file
108
tests/adapters/store/add-many-events.ts
Normal file
@@ -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<EventStoreFactory>(".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);
|
||||
});
|
||||
});
|
||||
91
tests/adapters/store/create-snapshot.ts
Normal file
91
tests/adapters/store/create-snapshot.ts
Normal file
@@ -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<EventStoreFactory>(".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,
|
||||
});
|
||||
});
|
||||
});
|
||||
25
tests/adapters/store/make-aggregate-reducer.ts
Normal file
25
tests/adapters/store/make-aggregate-reducer.ts
Normal file
@@ -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<EventStoreFactory>(".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");
|
||||
});
|
||||
});
|
||||
89
tests/adapters/store/make-event.ts
Normal file
89
tests/adapters/store/make-event.ts
Normal file
@@ -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<EventStoreFactory>(".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);
|
||||
});
|
||||
});
|
||||
120
tests/adapters/store/make-reducer.ts
Normal file
120
tests/adapters/store/make-reducer.ts
Normal file
@@ -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<EventStoreFactory>(".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);
|
||||
});
|
||||
});
|
||||
94
tests/adapters/store/once-projection.ts
Normal file
94
tests/adapters/store/once-projection.ts
Normal file
@@ -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<EventStoreFactory>("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!");
|
||||
}
|
||||
34
tests/adapters/store/providers/relations.ts
Normal file
34
tests/adapters/store/providers/relations.ts
Normal file
@@ -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<EventStoreFactory>("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]);
|
||||
});
|
||||
});
|
||||
42
tests/adapters/store/push-aggregate.ts
Normal file
42
tests/adapters/store/push-aggregate.ts
Normal file
@@ -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<EventStoreFactory>(".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",
|
||||
});
|
||||
});
|
||||
});
|
||||
62
tests/adapters/store/push-many-aggregates.ts
Normal file
62
tests/adapters/store/push-many-aggregates.ts
Normal file
@@ -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<EventStoreFactory>(".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",
|
||||
});
|
||||
});
|
||||
});
|
||||
103
tests/adapters/store/reduce.ts
Normal file
103
tests/adapters/store/reduce.ts
Normal file
@@ -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<EventStoreFactory>(".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);
|
||||
});
|
||||
});
|
||||
94
tests/adapters/store/replay-events.ts
Normal file
94
tests/adapters/store/replay-events.ts
Normal file
@@ -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<EventStoreFactory>(".replayEvents", (getEventStore) => {
|
||||
it("should replay events", async () => {
|
||||
const { store, projector } = await getEventStore();
|
||||
const stream = nanoid();
|
||||
|
||||
const record: Record<string, any> = {};
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
17
tests/adapters/utilities/describe.ts
Normal file
17
tests/adapters/utilities/describe.ts
Normal file
@@ -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<TEventFactory extends EventFactory>(
|
||||
name: string,
|
||||
runner: (getEventStore: EventStoreFn<TEventFactory>) => void,
|
||||
): (getEventStore: EventStoreFn<TEventFactory>) => void {
|
||||
return (getEventStore: EventStoreFn<TEventFactory>) => desc(name, () => runner(getEventStore));
|
||||
}
|
||||
|
||||
type EventStoreFn<TEventFactory extends EventFactory> = (options?: { hooks?: EventStoreHooks<TEventFactory> }) => Promise<{
|
||||
store: EventStore<TEventFactory, any, any>;
|
||||
projector: Projector<TEventFactory>;
|
||||
}>;
|
||||
Reference in New Issue
Block a user