From 183b7a3efc3284cf5ad4d68d29c4ccaa1724b957 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Sun, 28 Jan 2024 12:31:51 +0100 Subject: [PATCH] Added full implementation of the Workflow pattern as described by @yreynhout Updated the example of group checkout to use it. --- samples/hotelManagement/src/core/command.ts | 2 +- samples/hotelManagement/src/core/event.ts | 2 +- samples/hotelManagement/src/core/workflow.ts | 103 ++++++- .../groupCheckouts/groupCheckoutWorflow.ts | 269 ++++++++---------- 4 files changed, 224 insertions(+), 152 deletions(-) diff --git a/samples/hotelManagement/src/core/command.ts b/samples/hotelManagement/src/core/command.ts index f598ed6..59b4c56 100644 --- a/samples/hotelManagement/src/core/command.ts +++ b/samples/hotelManagement/src/core/command.ts @@ -5,7 +5,7 @@ export type Command< CommandData extends Record = Record, > = Flavour< Readonly<{ - type: Readonly; + type: CommandType; data: Readonly; }>, 'Command' diff --git a/samples/hotelManagement/src/core/event.ts b/samples/hotelManagement/src/core/event.ts index 8c2d666..37dde09 100644 --- a/samples/hotelManagement/src/core/event.ts +++ b/samples/hotelManagement/src/core/event.ts @@ -5,7 +5,7 @@ export type Event< EventData extends Record = Record, > = Flavour< Readonly<{ - type: Readonly; + type: EventType; data: Readonly; }>, 'Event' diff --git a/samples/hotelManagement/src/core/workflow.ts b/samples/hotelManagement/src/core/workflow.ts index 9674737..38f2999 100644 --- a/samples/hotelManagement/src/core/workflow.ts +++ b/samples/hotelManagement/src/core/workflow.ts @@ -3,17 +3,108 @@ import { Event } from './event'; /// Inspired by https://blog.bittacklr.be/the-workflow-pattern.html -export type WorkflowEvent = Extract< - Output, - { __brand?: 'Event' } ->; - export type Workflow< Input extends Event | Command, State, Output extends Event | Command, > = { - decide: (command: Input, state: State) => Output[]; + decide: (command: Input, state: State) => WorkflowOutput[]; evolve: (currentState: State, event: WorkflowEvent) => State; getInitialState: () => State; }; + +export type WorkflowEvent = Extract< + Output, + { __brand?: 'Event' } +>; + +export type WorkflowCommand = Extract< + Output, + { __brand?: 'Command' } +>; + +export type Reply = Command | Event; + +export type WorkflowOutput = + | { kind: 'Reply'; message: TOutput } + | { kind: 'Send'; message: WorkflowCommand } + | { kind: 'Publish'; message: WorkflowEvent } + | { + kind: 'Schedule'; + message: TOutput; + when: { afterInMs: number } | { at: Date }; + } + | { kind: 'Complete' } + | { kind: 'Accept' } + | { kind: 'Ignore'; reason: string } + | { kind: 'Error'; reason: string }; + +export const reply = ( + message: TOutput, +): WorkflowOutput => { + return { + kind: 'Reply', + message, + }; +}; + +export const send = ( + message: WorkflowCommand, +): WorkflowOutput => { + return { + kind: 'Send', + message, + }; +}; + +export const publish = ( + message: WorkflowEvent, +): WorkflowOutput => { + return { + kind: 'Publish', + message, + }; +}; + +export const schedule = ( + message: TOutput, + when: { afterInMs: number } | { at: Date }, +): WorkflowOutput => { + return { + kind: 'Schedule', + message, + when, + }; +}; + +export const complete = < + TOutput extends Command | Event, +>(): WorkflowOutput => { + return { + kind: 'Complete', + }; +}; + +export const ignore = ( + reason: string, +): WorkflowOutput => { + return { + kind: 'Ignore', + reason, + }; +}; + +export const error = ( + reason: string, +): WorkflowOutput => { + return { + kind: 'Error', + reason, + }; +}; + +export const accept = < + TOutput extends Command | Event, +>(): WorkflowOutput => { + return { kind: 'Accept' }; +}; diff --git a/samples/hotelManagement/src/workflows/groupCheckouts/groupCheckoutWorflow.ts b/samples/hotelManagement/src/workflows/groupCheckouts/groupCheckoutWorflow.ts index 3ba0745..c191abf 100644 --- a/samples/hotelManagement/src/workflows/groupCheckouts/groupCheckoutWorflow.ts +++ b/samples/hotelManagement/src/workflows/groupCheckouts/groupCheckoutWorflow.ts @@ -1,6 +1,16 @@ import { Command } from '#core/command'; import { Event } from '#core/event'; -import { Workflow, WorkflowEvent } from '#core/workflow'; +import { + Workflow, + WorkflowEvent, + WorkflowOutput, + accept, + complete, + error, + ignore, + publish, + send, +} from '#core/workflow'; import { Map } from 'immutable'; import { CheckOut, @@ -24,6 +34,15 @@ export type InitiateGroupCheckout = Command< } >; +export type TimeoutGroupCheckout = Command< + 'TimeoutGroupCheckout', + { + groupCheckoutId: string; + startedAt: Date; + timeOutAt: Date; + } +>; + //////////////////////////////////////////// ////////// EVENTS /////////////////////////////////////////// @@ -38,33 +57,6 @@ export type GroupCheckoutInitiated = Event< } >; -export type GuestCheckoutsInitiated = Event< - 'GuestCheckoutsInitiated', - { - groupCheckoutId: string; - initiatedGuestStayIds: string[]; - initiatedAt: Date; - } ->; - -export type GuestCheckoutCompletionRecorded = Event< - 'GuestCheckoutCompletionRecorded', - { - groupCheckoutId: string; - guestStayAccountId: string; - completedAt: Date; - } ->; - -export type GuestCheckoutFailureRecorded = Event< - 'GuestCheckoutFailureRecorded', - { - groupCheckoutId: string; - guestStayAccountId: string; - failedAt: Date; - } ->; - export type GroupCheckoutCompleted = Event< 'GroupCheckoutCompleted', { @@ -84,13 +76,16 @@ export type GroupCheckoutFailed = Event< } >; -export type GroupCheckoutEvent = - | GroupCheckoutInitiated - | GuestCheckoutsInitiated - | GuestCheckoutCompletionRecorded - | GuestCheckoutFailureRecorded - | GroupCheckoutCompleted - | GroupCheckoutFailed; +export type GroupCheckoutTimedOut = Event< + 'GroupCheckoutTimedOut', + { + groupCheckoutId: string; + incompleteCheckouts: string[]; + completedCheckouts: string[]; + failedCheckouts: string[]; + timedOutAt: Date; + } +>; //////////////////////////////////////////// ////////// Entity @@ -112,7 +107,6 @@ export const getInitialState = (): GroupCheckout => { export enum GuestStayStatus { Pending = 'Pending', - Initiated = 'Initiated', Completed = 'Completed', Failed = 'Failed', } @@ -124,60 +118,26 @@ export enum GuestStayStatus { export type GroupCheckoutInput = | InitiateGroupCheckout | GuestCheckedOut - | GuestCheckoutFailed; + | GuestCheckoutFailed + | TimeoutGroupCheckout; export type GroupCheckoutOutput = | GroupCheckoutInitiated | CheckOut - | GuestCheckoutCompletionRecorded - | GuestCheckoutFailureRecorded - | GuestCheckoutsInitiated | GroupCheckoutCompleted | GroupCheckoutFailed - | Ignored - | UnexpectedErrorOcurred; - -export type Ignored = { - type: 'Ignored'; - data: { - reason: IgnoredReason; - }; -}; - -export type UnexpectedErrorOcurred = { - type: 'ErrorOcurred'; - data: { - reason: ErrorReason; - }; -}; - -export type IgnoredReason = - | 'GroupCheckoutAlreadyInitiated' - | 'GuestCheckoutsInitiationAlreadyRecorded' - | 'GuestCheckoutAlreadyFinished' - | 'GroupCheckoutAlreadyFinished' - | 'GroupCheckoutDoesNotExist'; + | GroupCheckoutTimedOut; + +export enum IgnoredReason { + GroupCheckoutAlreadyInitiated = 'GroupCheckoutAlreadyInitiated', + GuestCheckoutWasNotPartOfGroupCheckout = 'GuestCheckoutWasNotPartOfGroupCheckout', + GuestCheckoutAlreadyFinished = 'GuestCheckoutAlreadyFinished', + GroupCheckoutAlreadyFinished = 'GroupCheckoutAlreadyFinished', + GroupCheckoutDoesNotExist = 'GroupCheckoutDoesNotExist', +} export type ErrorReason = 'UnknownInputType'; -const ignore = (reason: IgnoredReason): Ignored => { - return { - type: 'Ignored', - data: { - reason, - }, - }; -}; - -const error = (reason: ErrorReason): UnexpectedErrorOcurred => { - return { - type: 'ErrorOcurred', - data: { - reason, - }, - }; -}; - //////////////////////////////////////////// ////////// Evolve /////////////////////////////////////////// @@ -185,53 +145,56 @@ const error = (reason: ErrorReason): UnexpectedErrorOcurred => { export const decide = ( input: GroupCheckoutInput, state: GroupCheckout, -): GroupCheckoutOutput[] => { +): WorkflowOutput[] => { const { type, data } = input; switch (type) { case 'InitiateGroupCheckout': { if (state.status !== 'NotExisting') - return [ignore('GroupCheckoutAlreadyInitiated')]; - - const checkoutGuestStays = data.guestStayAccountIds.map( - (id) => { - return { - type: 'CheckOut', - data: { - guestStayAccountId: id, - now: data.now, - groupCheckoutId: data.groupCheckoutId, - }, - }; - }, - ); + return [ignore(IgnoredReason.GroupCheckoutAlreadyInitiated)]; + + const checkoutGuestStays = data.guestStayAccountIds.map((id) => { + return send({ + type: 'CheckOut', + data: { + guestStayAccountId: id, + now: data.now, + groupCheckoutId: data.groupCheckoutId, + }, + }); + }); return [ - { - type: 'GuestCheckoutsInitiated', + ...checkoutGuestStays, + publish({ + type: 'GroupCheckoutInitiated', data: { groupCheckoutId: data.groupCheckoutId, - initiatedGuestStayIds: data.guestStayAccountIds, + guestStayAccountIds: data.guestStayAccountIds, initiatedAt: data.now, + clerkId: data.clerkId, }, - }, - ...checkoutGuestStays, + }), ]; } case 'GuestCheckedOut': case 'GuestCheckoutFailed': { - if (!data.groupCheckoutId) return []; + if (!data.groupCheckoutId) + return [ignore(IgnoredReason.GuestCheckoutWasNotPartOfGroupCheckout)]; - if (state.status === 'NotExisting') return []; + if (state.status === 'NotExisting') + return [ignore(IgnoredReason.GroupCheckoutDoesNotExist)]; - if (state.status === 'Finished') return []; + if (state.status === 'Finished') + return [ignore(IgnoredReason.GuestCheckoutAlreadyFinished)]; const { guestStayAccountId, groupCheckoutId } = data; const guestCheckoutStatus = state.guestStayAccountIds.get(guestStayAccountId); - if (isAlreadyClosed(guestCheckoutStatus)) return []; + if (isAlreadyClosed(guestCheckoutStatus)) + return [ignore(IgnoredReason.GuestCheckoutAlreadyFinished)]; const guestStayAccountIds = state.guestStayAccountIds.set( guestStayAccountId, @@ -243,28 +206,30 @@ export const decide = ( const now = type === 'GuestCheckedOut' ? data.checkedOutAt : data.failedAt; - const finished: GroupCheckoutEvent = - type === 'GuestCheckedOut' - ? { - type: 'GuestCheckoutCompletionRecorded', - data: { - groupCheckoutId, - guestStayAccountId, - completedAt: now, - }, - } - : { - type: 'GuestCheckoutFailureRecorded', - data: { - groupCheckoutId, - guestStayAccountId, - failedAt: now, - }, - }; - return areAnyOngoingCheckouts(guestStayAccountIds) - ? [finished] - : [finished, finish(groupCheckoutId, state.guestStayAccountIds, now)]; + ? [accept()] + : [ + publish(finished(groupCheckoutId, state.guestStayAccountIds, now)), + complete(), + ]; + } + case 'TimeoutGroupCheckout': { + if (state.status === 'NotExisting') + return [ignore(IgnoredReason.GroupCheckoutDoesNotExist)]; + + if (state.status === 'Finished') + return [ignore(IgnoredReason.GroupCheckoutAlreadyFinished)]; + + return [ + publish( + timedOut( + data.groupCheckoutId, + state.guestStayAccountIds, + data.timeOutAt, + ), + ), + complete(), + ]; } default: { const _notExistingEventType: never = type; @@ -275,7 +240,10 @@ export const decide = ( export const evolve = ( state: GroupCheckout, - { type, data: event }: WorkflowEvent, + { + type, + data: event, + }: WorkflowEvent, ): GroupCheckout => { switch (type) { case 'GroupCheckoutInitiated': { @@ -289,33 +257,23 @@ export const evolve = ( ), }; } - case 'GuestCheckoutsInitiated': { - if (state.status !== 'Pending') return state; - - return { - status: 'Pending', - guestStayAccountIds: event.initiatedGuestStayIds.reduce( - (map, id) => map.set(id, GuestStayStatus.Initiated), - state.guestStayAccountIds, - ), - }; - } - case 'GuestCheckoutCompletionRecorded': - case 'GuestCheckoutFailureRecorded': { + case 'GuestCheckedOut': + case 'GuestCheckoutFailed': { if (state.status !== 'Pending') return state; return { ...state, guestStayAccountIds: state.guestStayAccountIds.set( event.guestStayAccountId, - type === 'GuestCheckoutCompletionRecorded' + type === 'GuestCheckedOut' ? GuestStayStatus.Completed : GuestStayStatus.Failed, ), }; } case 'GroupCheckoutCompleted': - case 'GroupCheckoutFailed': { + case 'GroupCheckoutFailed': + case 'GroupCheckoutTimedOut': { if (state.status !== 'Pending') return state; return { @@ -355,11 +313,11 @@ const checkoutsWith = ( ): string[] => Array.from(guestStayAccounts.filter((s) => s === status).values()); -const finish = ( +const finished = ( groupCheckoutId: string, guestStayAccounts: Map, now: Date, -): GroupCheckoutEvent => { +): GroupCheckoutCompleted | GroupCheckoutFailed => { return areAllCompleted(guestStayAccounts) ? { type: 'GroupCheckoutCompleted', @@ -385,3 +343,26 @@ const finish = ( }, }; }; + +const timedOut = ( + groupCheckoutId: string, + guestStayAccounts: Map, + now: Date, +): GroupCheckoutTimedOut => { + return { + type: 'GroupCheckoutTimedOut', + data: { + groupCheckoutId, + incompleteCheckouts: checkoutsWith( + guestStayAccounts, + GuestStayStatus.Pending, + ), + completedCheckouts: checkoutsWith( + guestStayAccounts, + GuestStayStatus.Completed, + ), + failedCheckouts: checkoutsWith(guestStayAccounts, GuestStayStatus.Failed), + timedOutAt: now, + }, + }; +};