diff --git a/.github/workflows/samples_hotelManagement.yml b/.github/workflows/samples_hotelManagement.yml index 452c29cd..40e78dad 100644 --- a/.github/workflows/samples_hotelManagement.yml +++ b/.github/workflows/samples_hotelManagement.yml @@ -1,4 +1,4 @@ -name: Samples - Events Versioning +name: Samples - Hotel Management on: # run it on push to the default repository branch diff --git a/samples/eventsVersioning/.vscode/settings.json b/samples/eventsVersioning/.vscode/settings.json index eb02b05e..2d91860e 100644 --- a/samples/eventsVersioning/.vscode/settings.json +++ b/samples/eventsVersioning/.vscode/settings.json @@ -1,9 +1,12 @@ { + "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" + "source.organizeImports": "explicit", + "source.fixAll.eslint": "explicit", + "source.addMissingImports": "always" }, "editor.tabSize": 2, @@ -12,5 +15,7 @@ "node_modules/": true, "dist/": true }, - "files.eol": "\n" + "files.eol": "\n", + + "typescript.preferences.importModuleSpecifier": "relative" } diff --git a/samples/eventsVersioning/src/02_upcasters/changedStructure.unit.test.ts b/samples/eventsVersioning/src/02_upcasters/changedStructure.unit.test.ts index d6af516b..dde3b688 100644 --- a/samples/eventsVersioning/src/02_upcasters/changedStructure.unit.test.ts +++ b/samples/eventsVersioning/src/02_upcasters/changedStructure.unit.test.ts @@ -1,7 +1,7 @@ import { Event } from '#core/event'; +import { JSONParser } from '#core/jsonParser'; import { ShoppingCartOpened as ShoppingCartOpenedV1 } from 'src/events/events.v1'; import { v4 as uuid } from 'uuid'; -import { JSONParser } from '#core/jsonParser'; export type Client = { id: string; @@ -11,8 +11,8 @@ export type Client = { export type ShoppingCartOpened = Event< 'ShoppingCartOpened', { - //renamed property shoppingCartId: string; + //new nested property instead of a single field client: Client; } >; diff --git a/samples/eventsVersioning/src/02_upcasters/newRequiredPropertyFromMetadata.unit.test.ts b/samples/eventsVersioning/src/02_upcasters/newRequiredPropertyFromMetadata.unit.test.ts index eee88161..f746a7a1 100644 --- a/samples/eventsVersioning/src/02_upcasters/newRequiredPropertyFromMetadata.unit.test.ts +++ b/samples/eventsVersioning/src/02_upcasters/newRequiredPropertyFromMetadata.unit.test.ts @@ -1,7 +1,7 @@ import { Event } from '#core/event'; +import { JSONParser } from '#core/jsonParser'; import { ShoppingCartOpened as ShoppingCartOpenedV1 } from 'src/events/events.v1'; import { v4 as uuid } from 'uuid'; -import { JSONParser } from '#core/jsonParser'; export type EventMetadata = { userId: string; @@ -14,9 +14,9 @@ export type EventWithMetadata = E & { export type ShoppingCartOpened = Event< 'ShoppingCartOpened', { - //renamed property shoppingCartId: string; clientId: string; + // new required property initializedBy: string; } >; diff --git a/samples/eventsVersioning/src/03_downcasters/changedStructure.unit.test.ts b/samples/eventsVersioning/src/03_downcasters/changedStructure.unit.test.ts index e23b27b0..923079b1 100644 --- a/samples/eventsVersioning/src/03_downcasters/changedStructure.unit.test.ts +++ b/samples/eventsVersioning/src/03_downcasters/changedStructure.unit.test.ts @@ -1,7 +1,7 @@ import { Event } from '#core/event'; +import { JSONParser } from '#core/jsonParser'; import { ShoppingCartOpened as ShoppingCartOpenedV1 } from 'src/events/events.v1'; import { v4 as uuid } from 'uuid'; -import { JSONParser } from '#core/jsonParser'; export type Client = { id: string; @@ -11,8 +11,8 @@ export type Client = { export type ShoppingCartOpened = Event< 'ShoppingCartOpened', { - //renamed property shoppingCartId: string; + //new nested property instead of a single field client: Client; } >; diff --git a/samples/eventsVersioning/src/04_transformations/multipleTransformationsWithDifferentEventTypes.int.test.ts b/samples/eventsVersioning/src/04_transformations/multipleTransformationsWithDifferentEventTypes.int.test.ts new file mode 100644 index 00000000..bd7dbd6e --- /dev/null +++ b/samples/eventsVersioning/src/04_transformations/multipleTransformationsWithDifferentEventTypes.int.test.ts @@ -0,0 +1,350 @@ +import { Event } from '#core/event'; +import { + EventStoreDBContainer, + StartedEventStoreDBContainer, +} from '#core/testing/eventStoreDB/eventStoreDBContainer'; +import { + EventData, + EventTypeToRecordedEvent, + jsonEvent, +} from '@eventstore/db-client'; +import { v4 as uuid } from 'uuid'; +import { EventStore, getEventStore } from '../core/eventStoreDB'; +import { ShoppingCartOpened as ShoppingCartOpenedV1 } from '../events/events.v1'; +import { + ShoppingCart, + ShoppingCartEvent, + ShoppingCartOpened, + ShoppingCartOpenedWithStatus, + ShoppingCartStatus, + evolve, +} from './shoppingCart'; + +export type ShoppingCartOpenedObsoleteVersions = + | ShoppingCartOpenedV1 + | ShoppingCartOpened; + +export type ShoppingCartPayload = + | ShoppingCartOpenedObsoleteVersions + | ShoppingCartEvent; + +export type ShoppingCartOpenedAllCompatible = Omit< + ShoppingCartOpenedV1, + 'type' +> & + Omit & + ShoppingCartOpenedWithStatus; + +export const upcastShoppingCartOpened = ({ + type, + data, +}: ShoppingCartOpenedObsoleteVersions): ShoppingCartOpenedWithStatus => { + switch (type) { + case 'ShoppingCartOpened': { + return { + type: 'ShoppingCartOpened.v3', + data: { + shoppingCartId: data.shoppingCartId, + client: { id: data.clientId, name: 'Unknown' }, + status: ShoppingCartStatus.Opened, + }, + }; + } + case 'ShoppingCartOpened.v2': { + return { + type: 'ShoppingCartOpened.v3', + data: { + shoppingCartId: data.shoppingCartId, + client: data.client, + status: ShoppingCartStatus.Opened, + }, + }; + } + default: { + const _: never = data; + return data; + } + } +}; + +export const downcastShoppingCartOpened = ({ + type, + data, +}: ShoppingCartOpenedWithStatus): ShoppingCartOpenedAllCompatible => { + return { + type, + data: { + ...data, + clientId: data.client.id, + }, + }; +}; + +export class EventTransformations { + #upcasters = new Map Event>(); + #downcasters = new Map Event>(); + + public register = { + upcaster: ( + eventType: string, + upcaster: (event: From) => To, + ) => { + this.#upcasters.set(eventType, (event) => upcaster(event as From)); + + return this.register; + }, + downcaster: ( + eventType: string, + downcaster: (event: From) => To, + ) => { + this.#downcasters.set(eventType, (event) => downcaster(event as From)); + + return this.register; + }, + }; + + public get = { + upcaster: (eventType: string) => this.#upcasters.get(eventType), + + downcaster: (eventType: string) => this.#downcasters.get(eventType), + }; +} + +export class EventParser { + constructor(private eventTransformations: EventTransformations) {} + + public parse( + recordedEvent: EventTypeToRecordedEvent, + ): E { + const upcaster = this.eventTransformations.get.upcaster(recordedEvent.type); + + const parsed = { type: recordedEvent.type, data: recordedEvent.data }; + + return (upcaster ? upcaster(parsed) : parsed); + } + + public stringify(event: E): EventData { + const downcaster = this.eventTransformations.get.downcaster(event.type); + + return jsonEvent(downcaster ? downcaster(event) : event); + } +} + +const getShoppingCart = ( + eventStore: EventStore, + parser: EventParser, + shoppingCartId: string, +): Promise => { + return eventStore.aggregateStream< + ShoppingCart, + ShoppingCartEvent, + ShoppingCartPayload + >(`shoppingCart-${shoppingCartId}`, { + getInitialState: () => { + return {} as ShoppingCart; + }, + evolve, + parse: (e) => parser.parse(e), + }); +}; + +describe('Multiple transformations with different event types', () => { + jest.setTimeout(180_000); + + let container: StartedEventStoreDBContainer; + let eventStore: EventStore; + + beforeAll(async () => { + container = await new EventStoreDBContainer().start(); + const client = container.getClient(); + eventStore = getEventStore(client); + }); + + afterAll(() => { + return container.stop(); + }); + + it('should read new event schema using transformations', async () => { + const eventTransformations = new EventTransformations(); + eventTransformations.register + .upcaster('ShoppingCartOpened', upcastShoppingCartOpened) + .upcaster('ShoppingCartOpened.v2', upcastShoppingCartOpened) + .downcaster('ShoppingCartOpened.v3', downcastShoppingCartOpened); + + const parser = new EventParser(eventTransformations); + + // Given + const eventV1: ShoppingCartOpenedV1 = { + type: 'ShoppingCartOpened', + data: { + clientId: uuid(), + shoppingCartId: uuid(), + }, + }; + + const eventV2: ShoppingCartOpened = { + type: 'ShoppingCartOpened.v2', + data: { + client: { id: uuid(), name: 'Oscar the Grouch' }, + shoppingCartId: uuid(), + }, + }; + + const eventV3: ShoppingCartOpenedWithStatus = { + type: 'ShoppingCartOpened.v3', + data: { + client: { id: uuid(), name: 'Big Bird' }, + shoppingCartId: uuid(), + status: ShoppingCartStatus.Pending, + }, + }; + + await eventStore.appendToStream( + `shoppingCart-${eventV1.data.shoppingCartId}`, + [eventV1], + { serialize: (e) => parser.stringify(e) }, + ); + + await eventStore.appendToStream( + `shoppingCart-${eventV2.data.shoppingCartId}`, + [eventV2], + { serialize: (e) => parser.stringify(e) }, + ); + + await eventStore.appendToStream( + `shoppingCart-${eventV3.data.shoppingCartId}`, + [eventV3], + { serialize: (e) => parser.stringify(e) }, + ); + + // When + const deserializedEvents = [ + ( + await eventStore.readStream( + `shoppingCart-${eventV1.data.shoppingCartId}`, + { parse: (e) => parser.parse(e) }, + ) + )[0], + ( + await eventStore.readStream( + `shoppingCart-${eventV2.data.shoppingCartId}`, + { parse: (e) => parser.parse(e) }, + ) + )[0], + ( + await eventStore.readStream( + `shoppingCart-${eventV3.data.shoppingCartId}`, + { parse: (e) => parser.parse(e) }, + ) + )[0], + ]; + + expect(deserializedEvents).toEqual([ + { + type: 'ShoppingCartOpened.v3', + data: { + client: { id: eventV1.data.clientId, name: 'Unknown' }, + shoppingCartId: eventV1.data.shoppingCartId, + status: ShoppingCartStatus.Opened, + }, + }, + { + type: 'ShoppingCartOpened.v3', + data: { + client: eventV2.data.client, + shoppingCartId: eventV2.data.shoppingCartId, + status: ShoppingCartStatus.Opened, + }, + }, + { + type: 'ShoppingCartOpened.v3', + data: { + // this is because we're taking event as it is, we could map it to not have this property + clientId: eventV3.data.client.id, + client: eventV3.data.client, + shoppingCartId: eventV3.data.shoppingCartId, + status: eventV3.data.status, + }, + }, + ]); + }); + + it('should get state using event transformations', async () => { + const eventTransformations = new EventTransformations(); + eventTransformations.register + .upcaster('ShoppingCartOpened', upcastShoppingCartOpened) + .upcaster('ShoppingCartOpened.v2', upcastShoppingCartOpened) + .downcaster('ShoppingCartOpened.v3', downcastShoppingCartOpened); + + const parser = new EventParser(eventTransformations); + + // Given + const eventV1: ShoppingCartOpenedV1 = { + type: 'ShoppingCartOpened', + data: { + clientId: uuid(), + shoppingCartId: uuid(), + }, + }; + + const eventV2: ShoppingCartOpened = { + type: 'ShoppingCartOpened.v2', + data: { + client: { id: uuid(), name: 'Oscar the Grouch' }, + shoppingCartId: uuid(), + }, + }; + + const eventV3: ShoppingCartOpenedWithStatus = { + type: 'ShoppingCartOpened.v3', + data: { + client: { id: uuid(), name: 'Big Bird' }, + shoppingCartId: uuid(), + status: ShoppingCartStatus.Pending, + }, + }; + + await eventStore.appendToStream( + `shoppingCart-${eventV1.data.shoppingCartId}`, + [eventV1], + ); + + await eventStore.appendToStream( + `shoppingCart-${eventV2.data.shoppingCartId}`, + [eventV2], + ); + + await eventStore.appendToStream( + `shoppingCart-${eventV3.data.shoppingCartId}`, + [eventV3], + ); + + // When + const shoppingCarts: (ShoppingCart | null)[] = [ + await getShoppingCart(eventStore, parser, eventV1.data.shoppingCartId), + await getShoppingCart(eventStore, parser, eventV2.data.shoppingCartId), + await getShoppingCart(eventStore, parser, eventV3.data.shoppingCartId), + ]; + + expect(shoppingCarts).toEqual([ + { + id: eventV1.data.shoppingCartId, + client: { id: eventV1.data.clientId, name: 'Unknown' }, + status: ShoppingCartStatus.Opened, + productItems: [], + }, + { + id: eventV2.data.shoppingCartId, + client: eventV2.data.client, + status: ShoppingCartStatus.Opened, + productItems: [], + }, + { + id: eventV3.data.shoppingCartId, + client: eventV3.data.client, + status: eventV3.data.status, + productItems: [], + }, + ]); + }); +}); diff --git a/samples/eventsVersioning/src/04_transformations/multipleTransformationsWithDifferentEventTypes.unit.test.ts b/samples/eventsVersioning/src/04_transformations/multipleTransformationsWithDifferentEventTypes.unit.test.ts index 18f487f1..69edc40f 100644 --- a/samples/eventsVersioning/src/04_transformations/multipleTransformationsWithDifferentEventTypes.unit.test.ts +++ b/samples/eventsVersioning/src/04_transformations/multipleTransformationsWithDifferentEventTypes.unit.test.ts @@ -1,44 +1,21 @@ import { Event } from '#core/event'; +import { JSONParser } from '#core/jsonParser'; import { ShoppingCartOpened as ShoppingCartOpenedV1 } from 'src/events/events.v1'; import { v4 as uuid } from 'uuid'; -import { JSONParser } from '#core/jsonParser'; - -export type Client = { - id: string; - name: string; -}; - -export type ShoppingCartOpened = Event< - 'ShoppingCartOpened.v2', - { - //renamed property - shoppingCartId: string; - client: Client; - } ->; - -enum ShoppingCartStatus { - Pending = 'Pending', - Opened = 'Opened', - Confirmed = 'Confirmed', - Canceled = 'Canceled', -} - -export type ShoppingCartOpenedWithStatus = Event< - 'ShoppingCartOpened.v3', - { - shoppingCartId: string; - client: Client; - // Adding new required property as nullable - status: ShoppingCartStatus; - } ->; +import { + ShoppingCartOpened, + ShoppingCartOpenedWithStatus, + ShoppingCartStatus, +} from './shoppingCart'; export type ShoppingCartOpenedObsoleteVersions = | ShoppingCartOpenedV1 | ShoppingCartOpened; -export type ShoppingCartOpenedAllVersions = Omit & +export type ShoppingCartOpenedAllCompatible = Omit< + ShoppingCartOpenedV1, + 'type' +> & Omit & ShoppingCartOpenedWithStatus; @@ -76,7 +53,7 @@ export const upcastShoppingCartOpened = ({ export const downcastShoppingCartOpened = ({ type, data, -}: ShoppingCartOpenedWithStatus): ShoppingCartOpenedAllVersions => { +}: ShoppingCartOpenedWithStatus): ShoppingCartOpenedAllCompatible => { return { type, data: { diff --git a/samples/eventsVersioning/src/04_transformations/shoppingCart.ts b/samples/eventsVersioning/src/04_transformations/shoppingCart.ts new file mode 100644 index 00000000..fc6439ce --- /dev/null +++ b/samples/eventsVersioning/src/04_transformations/shoppingCart.ts @@ -0,0 +1,143 @@ +import { Event } from '#core/event'; +import { merge } from '#core/utils'; +import { PricedProductItem } from '../events/events.v1'; + +export type Client = { + id: string; + name: string; +}; + +export type ShoppingCartOpened = Event< + 'ShoppingCartOpened.v2', + { + shoppingCartId: string; + //new nested property instead of a single field + client: Client; + } +>; + +export enum ShoppingCartStatus { + Pending = 'Pending', + Opened = 'Opened', + Confirmed = 'Confirmed', + Canceled = 'Canceled', +} + +export type ShoppingCartOpenedWithStatus = Event< + 'ShoppingCartOpened.v3', + { + shoppingCartId: string; + client: Client; + // Adding new required property as nullable + status: ShoppingCartStatus; + } +>; + +export type ShoppingCartEvent = + | ShoppingCartOpenedWithStatus + | { + type: 'ProductItemAddedToShoppingCart'; + data: { + shoppingCartId: string; + productItem: PricedProductItem; + }; + } + | { + type: 'ProductItemRemovedFromShoppingCart'; + data: { + shoppingCartId: string; + productItem: PricedProductItem; + }; + } + | { + type: 'ShoppingCartConfirmed'; + data: { + shoppingCartId: string; + confirmedAt: string; + }; + } + | { + type: 'ShoppingCartCanceled'; + data: { + shoppingCartId: string; + canceledAt: string; + }; + }; + +export type ShoppingCart = Readonly<{ + id: string; + client: Client; + status: ShoppingCartStatus; + productItems: PricedProductItem[]; + confirmedAt?: Date; + canceledAt?: Date; +}>; + +export const evolve = ( + state: ShoppingCart, + { type, data: event }: ShoppingCartEvent, +): ShoppingCart => { + switch (type) { + case 'ShoppingCartOpened.v3': + return { + id: event.shoppingCartId, + client: event.client, + productItems: [], + status: event.status, + }; + case 'ProductItemAddedToShoppingCart': { + const { productItems } = state; + const { productItem } = event; + + return { + ...state, + productItems: merge( + productItems, + productItem, + (p) => + p.productId === productItem.productId && + p.unitPrice === productItem.unitPrice, + (p) => { + return { + ...p, + quantity: p.quantity + productItem.quantity, + }; + }, + () => productItem, + ), + }; + } + case 'ProductItemRemovedFromShoppingCart': { + const { productItems } = state; + const { productItem } = event; + return { + ...state, + productItems: merge( + productItems, + productItem, + (p) => + p.productId === productItem.productId && + p.unitPrice === productItem.unitPrice, + (p) => { + return { + ...p, + quantity: p.quantity - productItem.quantity, + }; + }, + ), + }; + } + case 'ShoppingCartConfirmed': + return { + ...state, + status: ShoppingCartStatus.Confirmed, + confirmedAt: new Date(event.confirmedAt), + }; + case 'ShoppingCartCanceled': + return { + ...state, + status: ShoppingCartStatus.Canceled, + canceledAt: new Date(event.canceledAt), + }; + } +}; diff --git a/samples/eventsVersioning/src/05_explicitSerialisation/serde.int.test.ts b/samples/eventsVersioning/src/05_explicitSerialisation/serde.int.test.ts new file mode 100644 index 00000000..2097da83 --- /dev/null +++ b/samples/eventsVersioning/src/05_explicitSerialisation/serde.int.test.ts @@ -0,0 +1,375 @@ +import { v4 as uuid } from 'uuid'; +import { ShoppingCartStatus } from '../04_transformations/shoppingCart'; +import { EventStore, getEventStore } from '../core/eventStoreDB'; +import { + EventStoreDBContainer, + StartedEventStoreDBContainer, +} from '../core/testing/eventStoreDB/eventStoreDBContainer'; +import { PricedProductItem } from '../events/events.v1'; +import { + Client, + ShoppingCart, + ShoppingCartEvent, + evolve, +} from './shoppingCart'; + +////////////////////////////////////////// +//// SERIALIZATION +////////////////////////////////////////// + +export type ShoppingCartEventPayload = + | { + type: 'ShoppingCartOpened'; + data: { + shoppingCartId: string; + clientId: string; + }; + } + | { + type: 'ShoppingCartOpened.v2'; + data: { + shoppingCartId: string; + clientId: string; + client: Client; + }; + } + | { + type: 'ShoppingCartOpened.v3'; + data: { + shoppingCartId: string; + // obsolete, kept for backward and forward compatibility + clientId: string; + // New required property + client: Client; + // New required property + status: ShoppingCartStatus; + }; + } + | { + type: 'ProductItemAddedToShoppingCart'; + data: { + shoppingCartId: string; + productItem: PricedProductItem; + }; + } + | { + type: 'ProductItemRemovedFromShoppingCart'; + data: { + shoppingCartId: string; + productItem: PricedProductItem; + }; + } + | { + type: 'ShoppingCartConfirmed'; + data: { + shoppingCartId: string; + confirmedAt: string; + }; + } + | { + type: 'ShoppingCartCanceled'; + data: { + shoppingCartId: string; + canceledAt: string; + }; + }; + +export const ShoppingCartEventSerde = { + serialize: ({ type, data }: ShoppingCartEvent): ShoppingCartEventPayload => { + switch (type) { + case 'ShoppingCartOpened': { + return { + type: 'ShoppingCartOpened.v3', + data: { + shoppingCartId: data.shoppingCartId, + clientId: data.client.id, + client: data.client, + status: data.status, + }, + }; + } + case 'ProductItemAddedToShoppingCart': { + return { type, data }; + } + case 'ProductItemRemovedFromShoppingCart': { + return { type, data }; + } + case 'ShoppingCartConfirmed': { + return { + type, + data: { ...data, confirmedAt: data.confirmedAt.toISOString() }, + }; + } + case 'ShoppingCartCanceled': { + return { + type, + data: { ...data, canceledAt: data.canceledAt.toISOString() }, + }; + } + } + }, + deserialize: ({ + type, + data, + }: ShoppingCartEventPayload): ShoppingCartEvent => { + switch (type) { + case 'ShoppingCartOpened': { + return { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: data.shoppingCartId, + client: { id: data.clientId, name: 'Unknown' }, + status: ShoppingCartStatus.Opened, + }, + }; + } + case 'ShoppingCartOpened.v2': { + return { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: data.shoppingCartId, + client: data.client, + status: ShoppingCartStatus.Opened, + }, + }; + } + case 'ShoppingCartOpened.v3': { + return { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: data.shoppingCartId, + client: data.client, + status: data.status, + }, + }; + } + case 'ProductItemAddedToShoppingCart': { + return { type, data }; + } + case 'ProductItemRemovedFromShoppingCart': { + return { type, data }; + } + case 'ShoppingCartConfirmed': { + return { + type, + data: { ...data, confirmedAt: new Date(data.confirmedAt) }, + }; + } + case 'ShoppingCartCanceled': { + return { + type, + data: { ...data, canceledAt: new Date(data.canceledAt) }, + }; + } + } + }, +}; + +const getShoppingCart = ( + eventStore: EventStore, + shoppingCartId: string, +): Promise => { + return eventStore.aggregateStream< + ShoppingCart, + ShoppingCartEvent, + ShoppingCartEventPayload + >(`shoppingCart-${shoppingCartId}`, { + getInitialState: () => { + return {} as ShoppingCart; + }, + evolve, + parse: ShoppingCartEventSerde.deserialize, + }); +}; + +describe('Multiple transformations with different event types', () => { + jest.setTimeout(180_000); + + let container: StartedEventStoreDBContainer; + let eventStore: EventStore; + + beforeAll(async () => { + container = await new EventStoreDBContainer().start(); + const client = container.getClient(); + eventStore = getEventStore(client); + }); + + afterAll(() => { + return container.stop(); + }); + + it('should read new event schema using Serde', async () => { + const clientIds = [uuid(), uuid(), uuid()]; + + const eventV1: ShoppingCartEventPayload = { + type: 'ShoppingCartOpened', + data: { + clientId: clientIds[0], + shoppingCartId: uuid(), + }, + }; + + const eventV2: ShoppingCartEventPayload = { + type: 'ShoppingCartOpened.v2', + data: { + clientId: clientIds[1], + client: { id: uuid(), name: 'Oscar the Grouch' }, + shoppingCartId: uuid(), + }, + }; + + const eventV3: ShoppingCartEventPayload = { + type: 'ShoppingCartOpened.v3', + data: { + clientId: clientIds[2], + client: { id: uuid(), name: 'Big Bird' }, + shoppingCartId: uuid(), + status: ShoppingCartStatus.Pending, + }, + }; + + await eventStore.appendToStream( + `shoppingCart-${eventV1.data.shoppingCartId}`, + [eventV1], + ); + + await eventStore.appendToStream( + `shoppingCart-${eventV2.data.shoppingCartId}`, + [eventV2], + ); + + await eventStore.appendToStream( + `shoppingCart-${eventV3.data.shoppingCartId}`, + [eventV3], + ); + + // When + const deserializedEvents = [ + ( + await eventStore.readStream< + ShoppingCartEvent, + ShoppingCartEventPayload + >(`shoppingCart-${eventV1.data.shoppingCartId}`, { + parse: ShoppingCartEventSerde.deserialize, + }) + )[0], + ( + await eventStore.readStream< + ShoppingCartEvent, + ShoppingCartEventPayload + >(`shoppingCart-${eventV2.data.shoppingCartId}`, { + parse: ShoppingCartEventSerde.deserialize, + }) + )[0], + ( + await eventStore.readStream< + ShoppingCartEvent, + ShoppingCartEventPayload + >(`shoppingCart-${eventV3.data.shoppingCartId}`, { + parse: ShoppingCartEventSerde.deserialize, + }) + )[0], + ]; + + expect(deserializedEvents).toEqual([ + { + type: 'ShoppingCartOpened', + data: { + client: { id: eventV1.data.clientId, name: 'Unknown' }, + shoppingCartId: eventV1.data.shoppingCartId, + status: ShoppingCartStatus.Opened, + }, + }, + { + type: 'ShoppingCartOpened', + data: { + client: eventV2.data.client, + shoppingCartId: eventV2.data.shoppingCartId, + status: ShoppingCartStatus.Opened, + }, + }, + { + type: 'ShoppingCartOpened', + data: { + client: eventV3.data.client, + shoppingCartId: eventV3.data.shoppingCartId, + status: eventV3.data.status, + }, + }, + ]); + }); + + it('should get state using Serde', async () => { + const clientIds = [uuid(), uuid(), uuid()]; + + const eventV1: ShoppingCartEventPayload = { + type: 'ShoppingCartOpened', + data: { + clientId: clientIds[0], + shoppingCartId: uuid(), + }, + }; + + const eventV2: ShoppingCartEventPayload = { + type: 'ShoppingCartOpened.v2', + data: { + clientId: clientIds[1], + client: { id: uuid(), name: 'Oscar the Grouch' }, + shoppingCartId: uuid(), + }, + }; + + const eventV3: ShoppingCartEventPayload = { + type: 'ShoppingCartOpened.v3', + data: { + clientId: clientIds[2], + client: { id: uuid(), name: 'Big Bird' }, + shoppingCartId: uuid(), + status: ShoppingCartStatus.Pending, + }, + }; + + await eventStore.appendToStream( + `shoppingCart-${eventV1.data.shoppingCartId}`, + [eventV1], + ); + + await eventStore.appendToStream( + `shoppingCart-${eventV2.data.shoppingCartId}`, + [eventV2], + ); + + await eventStore.appendToStream( + `shoppingCart-${eventV3.data.shoppingCartId}`, + [eventV3], + ); + + // When + const shoppingCarts: (ShoppingCart | null)[] = [ + await getShoppingCart(eventStore, eventV1.data.shoppingCartId), + await getShoppingCart(eventStore, eventV2.data.shoppingCartId), + await getShoppingCart(eventStore, eventV3.data.shoppingCartId), + ]; + + expect(shoppingCarts).toEqual([ + { + id: eventV1.data.shoppingCartId, + client: { id: eventV1.data.clientId, name: 'Unknown' }, + status: ShoppingCartStatus.Opened, + productItems: [], + }, + { + id: eventV2.data.shoppingCartId, + client: eventV2.data.client, + status: ShoppingCartStatus.Opened, + productItems: [], + }, + { + id: eventV3.data.shoppingCartId, + client: eventV3.data.client, + status: eventV3.data.status, + productItems: [], + }, + ]); + }); +}); diff --git a/samples/eventsVersioning/src/05_explicitSerialisation/serde.unit.test.ts b/samples/eventsVersioning/src/05_explicitSerialisation/serde.unit.test.ts new file mode 100644 index 00000000..c2912c7e --- /dev/null +++ b/samples/eventsVersioning/src/05_explicitSerialisation/serde.unit.test.ts @@ -0,0 +1,233 @@ +import { v4 as uuid } from 'uuid'; +import { ShoppingCartStatus } from '../04_transformations/shoppingCart'; +import { PricedProductItem } from '../events/events.v1'; +import { Client, ShoppingCartEvent } from './shoppingCart'; + +////////////////////////////////////////// +//// SERIALIZATION +////////////////////////////////////////// + +export type ShoppingCartEventPayload = + | { + type: 'ShoppingCartOpened'; + data: { + shoppingCartId: string; + clientId: string; + }; + } + | { + type: 'ShoppingCartOpened.v2'; + data: { + shoppingCartId: string; + clientId: string; + client: Client; + }; + } + | { + type: 'ShoppingCartOpened.v3'; + data: { + shoppingCartId: string; + // obsolete, kept for backward and forward compatibility + clientId: string; + // New required property + client: Client; + // New required property + status: ShoppingCartStatus; + }; + } + | { + type: 'ProductItemAddedToShoppingCart'; + data: { + shoppingCartId: string; + productItem: PricedProductItem; + }; + } + | { + type: 'ProductItemRemovedFromShoppingCart'; + data: { + shoppingCartId: string; + productItem: PricedProductItem; + }; + } + | { + type: 'ShoppingCartConfirmed'; + data: { + shoppingCartId: string; + confirmedAt: string; + }; + } + | { + type: 'ShoppingCartCanceled'; + data: { + shoppingCartId: string; + canceledAt: string; + }; + }; + +export const ShoppingCartEventSerde = { + serialize: ({ type, data }: ShoppingCartEvent): ShoppingCartEventPayload => { + switch (type) { + case 'ShoppingCartOpened': { + return { + type: 'ShoppingCartOpened.v3', + data: { + shoppingCartId: data.shoppingCartId, + clientId: data.client.id, + client: data.client, + status: data.status, + }, + }; + } + case 'ProductItemAddedToShoppingCart': { + return { type, data }; + } + case 'ProductItemRemovedFromShoppingCart': { + return { type, data }; + } + case 'ShoppingCartConfirmed': { + return { + type, + data: { ...data, confirmedAt: data.confirmedAt.toISOString() }, + }; + } + case 'ShoppingCartCanceled': { + return { + type, + data: { ...data, canceledAt: data.canceledAt.toISOString() }, + }; + } + } + }, + deserialize: ({ + type, + data, + }: ShoppingCartEventPayload): ShoppingCartEvent => { + switch (type) { + case 'ShoppingCartOpened': { + return { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: data.shoppingCartId, + client: { id: data.clientId, name: 'Unknown' }, + status: ShoppingCartStatus.Opened, + }, + }; + } + case 'ShoppingCartOpened.v2': { + return { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: data.shoppingCartId, + client: data.client, + status: ShoppingCartStatus.Opened, + }, + }; + } + case 'ShoppingCartOpened.v3': { + return { + type: 'ShoppingCartOpened', + data: { + shoppingCartId: data.shoppingCartId, + client: data.client, + status: data.status, + }, + }; + } + case 'ProductItemAddedToShoppingCart': { + return { type, data }; + } + case 'ProductItemRemovedFromShoppingCart': { + return { type, data }; + } + case 'ShoppingCartConfirmed': { + return { + type, + data: { ...data, confirmedAt: new Date(data.confirmedAt) }, + }; + } + case 'ShoppingCartCanceled': { + return { + type, + data: { ...data, canceledAt: new Date(data.canceledAt) }, + }; + } + } + }, +}; + +describe('Multiple transformations with different event types', () => { + it('using explicit derserialisation with Serde should be forward compatible', () => { + const clientIds = [uuid(), uuid(), uuid()]; + + const eventV1: ShoppingCartEventPayload = { + type: 'ShoppingCartOpened', + data: { + clientId: clientIds[0], + shoppingCartId: uuid(), + }, + }; + + const eventV2: ShoppingCartEventPayload = { + type: 'ShoppingCartOpened.v2', + data: { + clientId: clientIds[1], + client: { id: uuid(), name: 'Oscar the Grouch' }, + shoppingCartId: uuid(), + }, + }; + + const eventV3: ShoppingCartEventPayload = { + type: 'ShoppingCartOpened.v3', + data: { + clientId: clientIds[2], + client: { id: uuid(), name: 'Big Bird' }, + shoppingCartId: uuid(), + status: ShoppingCartStatus.Pending, + }, + }; + + const events = [eventV1, eventV2, eventV3]; + + // Given + const serializedEvents = events.map((e) => { + return { + type: e.type, + payload: JSON.stringify(e), + }; + }); + + // When + const deserializedEvents = serializedEvents.map((e) => + ShoppingCartEventSerde.deserialize( + JSON.parse(e.payload) as ShoppingCartEventPayload, + ), + ); + + expect(deserializedEvents).toEqual([ + { + type: 'ShoppingCartOpened', + data: { + client: { id: eventV1.data.clientId, name: 'Unknown' }, + shoppingCartId: eventV1.data.shoppingCartId, + status: ShoppingCartStatus.Opened, + }, + }, + { + type: 'ShoppingCartOpened', + data: { + client: eventV2.data.client, + shoppingCartId: eventV2.data.shoppingCartId, + status: ShoppingCartStatus.Opened, + }, + }, + { + type: 'ShoppingCartOpened', + data: { + client: eventV3.data.client, + shoppingCartId: eventV3.data.shoppingCartId, + status: eventV3.data.status, + }, + }, + ]); + }); +}); diff --git a/samples/eventsVersioning/src/05_explicitSerialisation/shoppingCart.ts b/samples/eventsVersioning/src/05_explicitSerialisation/shoppingCart.ts new file mode 100644 index 00000000..e8ee4403 --- /dev/null +++ b/samples/eventsVersioning/src/05_explicitSerialisation/shoppingCart.ts @@ -0,0 +1,130 @@ +import { merge } from '#core/utils'; +import { PricedProductItem } from '../events/events.v1'; + +export type Client = { + id: string; + name: string; +}; + +enum ShoppingCartStatus { + Pending = 'Pending', + Opened = 'Opened', + Confirmed = 'Confirmed', + Canceled = 'Canceled', +} + +export type ShoppingCartEvent = + | { + type: 'ShoppingCartOpened'; + data: { + shoppingCartId: string; + client: Client; + status: ShoppingCartStatus; + }; + } + | { + type: 'ProductItemAddedToShoppingCart'; + data: { + shoppingCartId: string; + productItem: PricedProductItem; + }; + } + | { + type: 'ProductItemRemovedFromShoppingCart'; + data: { + shoppingCartId: string; + productItem: PricedProductItem; + }; + } + | { + type: 'ShoppingCartConfirmed'; + data: { + shoppingCartId: string; + confirmedAt: Date; + }; + } + | { + type: 'ShoppingCartCanceled'; + data: { + shoppingCartId: string; + canceledAt: Date; + }; + }; + +export type ShoppingCart = Readonly<{ + id: string; + client: Client; + status: ShoppingCartStatus; + productItems: PricedProductItem[]; + confirmedAt?: Date; + canceledAt?: Date; +}>; + +export const evolve = ( + state: ShoppingCart, + { type, data: event }: ShoppingCartEvent, +): ShoppingCart => { + switch (type) { + case 'ShoppingCartOpened': + return { + id: event.shoppingCartId, + client: event.client, + productItems: [], + status: event.status, + }; + case 'ProductItemAddedToShoppingCart': { + const { productItems } = state; + const { productItem } = event; + + return { + ...state, + productItems: merge( + productItems, + productItem, + (p) => + p.productId === productItem.productId && + p.unitPrice === productItem.unitPrice, + (p) => { + return { + ...p, + quantity: p.quantity + productItem.quantity, + }; + }, + () => productItem, + ), + }; + } + case 'ProductItemRemovedFromShoppingCart': { + const { productItems } = state; + const { productItem } = event; + return { + ...state, + productItems: merge( + productItems, + productItem, + (p) => + p.productId === productItem.productId && + p.unitPrice === productItem.unitPrice, + (p) => { + return { + ...p, + quantity: p.quantity - productItem.quantity, + }; + }, + ), + }; + } + case 'ShoppingCartConfirmed': + return { + ...state, + status: ShoppingCartStatus.Confirmed, + confirmedAt: event.confirmedAt, + }; + case 'ShoppingCartCanceled': + return { + ...state, + status: ShoppingCartStatus.Canceled, + canceledAt: event.canceledAt, + }; + } +}; diff --git a/samples/eventsVersioning/src/core/eventStoreDB.ts b/samples/eventsVersioning/src/core/eventStoreDB.ts new file mode 100644 index 00000000..5a95a852 --- /dev/null +++ b/samples/eventsVersioning/src/core/eventStoreDB.ts @@ -0,0 +1,135 @@ +import { + ANY, + AppendExpectedRevision, + EventData, + EventStoreDBClient, + EventType, + EventTypeToRecordedEvent, + StreamNotFoundError, + jsonEvent, +} from '@eventstore/db-client'; +import { Event } from './event'; + +export interface EventStore { + aggregateStream( + streamName: string, + options: { + evolve: (currentState: Entity, event: E) => Entity; + getInitialState: () => Entity; + parse?: (recordedEvent: EventTypeToRecordedEvent) => E | null; + }, + ): Promise; + + readStream( + streamName: string, + options?: { + parse?: (recordedEvent: EventTypeToRecordedEvent) => E | null; + }, + ): Promise; + + appendToStream( + streamId: string, + events: E[], + options?: { + expectedRevision?: AppendExpectedRevision; + serialize?: (resolvedEvent: Event) => EventType; + }, + ): Promise; +} + +export const getEventStore = (eventStore: EventStoreDBClient): EventStore => { + return { + aggregateStream: async ( + streamName: string, + options: { + evolve: (currentState: Entity, event: E) => Entity; + getInitialState: () => Entity; + parse?: (recordedEvent: EventTypeToRecordedEvent) => E | null; + }, + ): Promise => { + try { + const { evolve, getInitialState, parse } = options; + + let state = getInitialState(); + + for await (const { event } of eventStore.readStream( + streamName, + )) { + if (!event) continue; + + const parsedEvent = + (parse ? parse(event) : null) ?? + { + type: event.type, + data: event.data, + }; + + state = evolve(state, parsedEvent); + } + return state; + } catch (error) { + if (error instanceof StreamNotFoundError) { + return null; + } + + throw error; + } + }, + readStream: async ( + streamName: string, + options?: { + parse?: (recordedEvent: EventTypeToRecordedEvent) => E | null; + }, + ): Promise => { + const parse = options?.parse; + + const events: E[] = []; + + try { + for await (const { event } of eventStore.readStream( + streamName, + )) { + if (!event) continue; + + const parsedEvent = + (parse ? parse(event) : null) ?? + { + type: event.type, + data: event.data, + }; + + events.push(parsedEvent); + } + return events; + } catch (error) { + if (error instanceof StreamNotFoundError) { + return []; + } + + throw error; + } + }, + appendToStream: async ( + streamId: string, + events: E[], + options?: { + expectedRevision?: AppendExpectedRevision; + serialize?: (resolvedEvent: Event) => EventData; + }, + ): Promise => { + const serializedEvents = events.map((e) => { + return options?.serialize ? options?.serialize(e) : jsonEvent(e); + }); + + const appendResult = await eventStore.appendToStream( + streamId, + serializedEvents, + { + expectedRevision: options?.expectedRevision ?? ANY, + }, + ); + + return appendResult.nextExpectedRevision; + }, + }; +}; diff --git a/samples/eventsVersioning/src/core/utils.ts b/samples/eventsVersioning/src/core/utils.ts new file mode 100644 index 00000000..35e69177 --- /dev/null +++ b/samples/eventsVersioning/src/core/utils.ts @@ -0,0 +1,37 @@ +export const merge = ( + array: T[], + item: T, + where: (current: T) => boolean, + onExisting: (current: T) => T, + onNotFound: () => T | undefined = () => undefined, +) => { + let wasFound = false; + + const result = array + // merge the existing item if matches condition + .map((p: T) => { + if (!where(p)) return p; + + wasFound = true; + return onExisting(p); + }) + // filter out item if undefined was returned + // for cases of removal + .filter((p) => p !== undefined) + // make TypeScript happy + .map((p) => { + if (!p) throw Error('That should not happen'); + + return p; + }); + + // if item was not found and onNotFound action is defined + // try to generate new item + if (!wasFound) { + const result = onNotFound(); + + if (result !== undefined) return [...array, item]; + } + + return result; +}; diff --git a/samples/eventsVersioning/src/events/events.v1.ts b/samples/eventsVersioning/src/events/events.v1.ts index df927700..5c3ce456 100644 --- a/samples/eventsVersioning/src/events/events.v1.ts +++ b/samples/eventsVersioning/src/events/events.v1.ts @@ -37,7 +37,7 @@ export type ShoppingCartConfirmed = Event< 'ShoppingCartConfirmed', { shoppingCartId: string; - confirmedAt: Date; + confirmedAt: string; } >; @@ -45,11 +45,11 @@ export type ShoppingCartCanceled = Event< 'ShoppingCartCanceled', { shoppingCartId: string; - canceledAt: Date; + canceledAt: string; } >; -export type ShoppingCartEvent = +export type OriginalShoppingCartEvent = | ShoppingCartOpened | ProductItemAddedToShoppingCart | ProductItemRemovedFromShoppingCart diff --git a/samples/hotelManagement/src/workflows/groupCheckouts/groupCheckoutWorflow.ts b/samples/hotelManagement/src/workflows/groupCheckouts/groupCheckoutWorflow.ts index 99743d1a..3ba07459 100644 --- a/samples/hotelManagement/src/workflows/groupCheckouts/groupCheckoutWorflow.ts +++ b/samples/hotelManagement/src/workflows/groupCheckouts/groupCheckoutWorflow.ts @@ -136,6 +136,7 @@ export type GroupCheckoutOutput = | GroupCheckoutFailed | Ignored | UnexpectedErrorOcurred; + export type Ignored = { type: 'Ignored'; data: {