From 5e99704e0c4875717bfadd7bd1263872167dce2f Mon Sep 17 00:00:00 2001 From: Kevin Bader Date: Tue, 9 Apr 2019 15:43:25 +0200 Subject: [PATCH 1/2] API: Update workflowitem billingDate constraints --- api/src/service/cache2.spec.ts | 25 +++++++++++-------- api/src/service/cache2.ts | 8 +++--- .../service/domain/workflow/workflowitem.ts | 20 ++++++++++----- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/api/src/service/cache2.spec.ts b/api/src/service/cache2.spec.ts index 0a64e6fa4..4c18093f8 100644 --- a/api/src/service/cache2.spec.ts +++ b/api/src/service/cache2.spec.ts @@ -3,18 +3,16 @@ import * as isEmpty from "lodash.isempty"; import { Ctx } from "../lib/ctx"; import * as Result from "../result"; -import { Cache2, initCache, updateAggregates, getCacheInstance } from "./cache2"; -import * as ProjectCreated from "../service/domain/workflow/project_created"; -import * as ProjectClosed from "../service/domain/workflow/project_closed"; import * as ProjectAssigned from "../service/domain/workflow/project_assigned"; -import * as SubprojectCreated from "../service/domain/workflow/subproject_created"; +import * as ProjectClosed from "../service/domain/workflow/project_closed"; +import * as ProjectCreated from "../service/domain/workflow/project_created"; import * as SubprojectAssigned from "../service/domain/workflow/subproject_assigned"; import * as SubprojectClosed from "../service/domain/workflow/subproject_closed"; -import * as WorkflowitemCreated from "../service/domain/workflow/workflowitem_created"; +import * as SubprojectCreated from "../service/domain/workflow/subproject_created"; import * as WorkflowitemAssigned from "../service/domain/workflow/workflowitem_assigned"; import * as WorkflowitemClosed from "../service/domain/workflow/workflowitem_closed"; - -import { BusinessEvent } from "./domain/business_event"; +import * as WorkflowitemCreated from "../service/domain/workflow/workflowitem_created"; +import { Cache2, getCacheInstance, initCache, updateAggregates } from "./cache2"; import { NotFound } from "./domain/errors/not_found"; describe("The cache updates", () => { @@ -138,19 +136,22 @@ describe("The cache updates", () => { // Check if lookup for the first project is correct const lookUpForFirstProject = lookUp.get("p-id0"); - if (!lookUpForFirstProject) + if (!lookUpForFirstProject) { return assert.fail(undefined, undefined, "Lookup for first project not found"); + } assert.isFalse(isEmpty(lookUpForFirstProject)); assert.hasAllKeys(lookUpForFirstProject, ["s-id0", "s-id2", "s-id4"]); // Check if lookup for the second project is correct const lookUpForSecondProject = lookUp.get("p-id1"); - if (!lookUpForSecondProject) + if (!lookUpForSecondProject) { return assert.fail(undefined, undefined, "Lookup for second project not found"); + } assert.isFalse(isEmpty(lookUpForSecondProject)); assert.hasAllKeys(lookUpForSecondProject, ["s-id1", "s-id3"]); }); }); + context("workflowitem aggregates", async () => { const defaultCtx: Ctx = { requestId: "", @@ -237,15 +238,17 @@ describe("The cache updates", () => { // Check if lookup for the first subproject is correct const lookUpForFirstSubproject = lookUp.get("s-id0"); - if (!lookUpForFirstSubproject) + if (!lookUpForFirstSubproject) { return assert.fail(undefined, undefined, "Lookup for first Subproject not found"); + } assert.isFalse(isEmpty(lookUpForFirstSubproject)); assert.hasAllKeys(lookUpForFirstSubproject, ["w-id0", "w-id2", "w-id4"]); // Check if lookup for the second subproject is correct const lookUpForSecondSubproject = lookUp.get("s-id1"); - if (!lookUpForSecondSubproject) + if (!lookUpForSecondSubproject) { return assert.fail(undefined, undefined, "Lookup for second Subproject not found"); + } assert.isFalse(isEmpty(lookUpForSecondSubproject)); assert.hasAllKeys(lookUpForSecondSubproject, ["w-id1", "w-id3"]); }); diff --git a/api/src/service/cache2.ts b/api/src/service/cache2.ts index 2015fd8af..88fa6eae1 100644 --- a/api/src/service/cache2.ts +++ b/api/src/service/cache2.ts @@ -483,9 +483,11 @@ export function updateAggregates(ctx: Ctx, cache: Cache2, newEvents: BusinessEve for (const workflowitem of workflowitems) { cache.cachedWorkflowItems.set(workflowitem.id, workflowitem); const lookUp = cache.cachedWorkflowitemLookup.get(workflowitem.subprojectId); - lookUp === undefined - ? cache.cachedWorkflowitemLookup.set(workflowitem.subprojectId, new Set([workflowitem.id])) - : lookUp.add(workflowitem.id); + if (lookUp === undefined) { + cache.cachedWorkflowitemLookup.set(workflowitem.subprojectId, new Set([workflowitem.id])); + } else { + lookUp.add(workflowitem.id); + } } } diff --git a/api/src/service/domain/workflow/workflowitem.ts b/api/src/service/domain/workflow/workflowitem.ts index 8120f9c3d..f9086be5b 100644 --- a/api/src/service/domain/workflow/workflowitem.ts +++ b/api/src/service/domain/workflow/workflowitem.ts @@ -3,6 +3,7 @@ import Joi = require("joi"); import Intent from "../../../authz/intents"; import * as Result from "../../../result"; import * as AdditionalData from "../additional_data"; +import { BusinessEvent } from "../business_event"; import { canAssumeIdentity } from "../organization/auth_token"; import { Identity } from "../organization/identity"; import { ServiceUser } from "../organization/service_user"; @@ -10,7 +11,6 @@ import { Permissions } from "../permissions"; import { StoredDocument } from "./document"; import * as Subproject from "./subproject"; import { WorkflowitemTraceEvent, workflowitemTraceEventSchema } from "./workflowitem_trace_event"; -import { BusinessEvent } from "../business_event"; export type Id = string; @@ -85,11 +85,19 @@ const schema = Joi.object().keys({ // TODO: we should also check the amount type billingDate: Joi.date() .iso() - .when("status", { - is: Joi.valid("closed"), - then: Joi.required(), - otherwise: Joi.optional(), - }), + .when("amountType", { + is: Joi.valid("N/A"), + then: Joi.forbidden(), + }) + .concat( + Joi.date() + .iso() + .when("status", { + is: Joi.valid("closed"), + then: Joi.required(), + otherwise: Joi.optional(), + }), + ), amount: Joi.string() .when("amountType", { is: Joi.valid("disbursed", "allocated"), From b26909db29ad89f2a9199b9426e1644ad1cf7b47 Mon Sep 17 00:00:00 2001 From: Kevin Bader Date: Fri, 5 Apr 2019 08:42:18 +0200 Subject: [PATCH 2/2] API: Remove 'immer' dependency 'immer' didn't really work for us and led to all kinds of hard-to-debug problems. Instead of relying on 'immer' to handle changes during event sourcing, we now carefully deep-copy relevant data in the `*_eventsourcing.ts` files and have the event-specific modules `mutate` the aggregate in-place. This makes the latter very simple; at the same time we ensure that the aggregates stored in the cache aren't changed outside the cache. Affected aggregates: - project - subproject - workflowitem --- CHANGELOG.md | 4 + api/package-lock.json | 46 ++-- api/package.json | 1 - api/src/http_errors/bad_request.ts | 16 ++ api/src/service/cache2.spec.ts | 6 +- api/src/service/cache2.ts | 23 +- .../service/domain/workflow/project_assign.ts | 5 +- .../domain/workflow/project_assigned.ts | 29 ++- .../service/domain/workflow/project_close.ts | 11 +- .../service/domain/workflow/project_closed.ts | 27 ++- .../service/domain/workflow/project_create.ts | 9 +- .../domain/workflow/project_eventsourcing.ts | 197 ++++++++++++----- .../workflow/project_permission_grant.ts | 6 +- .../workflow/project_permission_granted.ts | 27 ++- .../workflow/project_permission_revoke.ts | 6 +- .../workflow/project_permission_revoked.ts | 31 +-- .../project_projected_budget_delete.ts | 22 +- .../project_projected_budget_deleted.ts | 32 +-- .../project_projected_budget_update.ts | 27 ++- .../project_projected_budget_updated.ts | 29 +-- .../service/domain/workflow/project_update.ts | 17 +- .../domain/workflow/project_updated.ts | 53 ++--- .../domain/workflow/subproject_assign.ts | 9 +- .../domain/workflow/subproject_assigned.ts | 29 ++- .../domain/workflow/subproject_close.ts | 5 +- .../domain/workflow/subproject_closed.ts | 27 ++- .../domain/workflow/subproject_create.ts | 8 +- .../workflow/subproject_eventsourcing.ts | 207 ++++++++++++------ .../workflow/subproject_permission_grant.ts | 9 +- .../workflow/subproject_permission_granted.ts | 27 ++- .../workflow/subproject_permission_revoke.ts | 8 +- .../workflow/subproject_permission_revoked.ts | 29 +-- .../subproject_projected_budget_delete.ts | 21 +- .../subproject_projected_budget_deleted.ts | 32 +-- .../subproject_projected_budget_update.ts | 17 +- .../subproject_projected_budget_updated.ts | 29 +-- .../domain/workflow/subproject_update.ts | 21 +- .../domain/workflow/subproject_updated.ts | 51 ++--- .../service/domain/workflow/workflowitem.ts | 1 - .../domain/workflow/workflowitem_assign.ts | 7 +- .../domain/workflow/workflowitem_assigned.ts | 29 ++- .../domain/workflow/workflowitem_close.ts | 8 +- .../domain/workflow/workflowitem_closed.ts | 43 ++-- .../domain/workflow/workflowitem_create.ts | 4 +- .../workflow/workflowitem_eventsourcing.ts | 197 ++++++++++++----- .../workflow/workflowitem_permission_grant.ts | 8 +- .../workflowitem_permission_granted.ts | 27 ++- .../workflowitem_permission_revoke.ts | 8 +- .../workflowitem_permission_revoked.ts | 29 +-- .../domain/workflow/workflowitem_update.ts | 17 +- .../domain/workflow/workflowitem_updated.ts | 101 ++++----- .../domain/workflow/workflowitems_reorder.ts | 16 +- .../workflow/workflowitems_reordered.ts | 27 ++- .../project_projected_budget_delete.ts | 8 +- .../project_projected_budget_update.ts | 8 +- 55 files changed, 1023 insertions(+), 668 deletions(-) create mode 100644 api/src/http_errors/bad_request.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c260c6e..2768912e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Fixed + +- [API] Increased the stability of the event sourcing code by replacing the "immer" dependency with a custom implementation. + ## [1.0.0-beta.7] - 2019-04-03 ### Added diff --git a/api/package-lock.json b/api/package-lock.json index 7660714de..7ab83ef66 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -2078,7 +2078,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -2099,12 +2100,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2119,17 +2122,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -2246,7 +2252,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -2258,6 +2265,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -2272,6 +2280,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -2279,12 +2288,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -2303,6 +2314,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -2383,7 +2395,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -2395,6 +2408,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -2480,7 +2494,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -2516,6 +2531,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -2535,6 +2551,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -2578,12 +2595,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, @@ -2890,11 +2909,6 @@ "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", "dev": true }, - "immer": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-2.1.4.tgz", - "integrity": "sha512-6UPbG/DIXFSWp10oJJaCPl5/lp5GhGEscDH0QGYKc5EMT5PLZ9+L8hhyc44zRHksI7CQXJp8r6nlDR3n09X6SA==" - }, "import-fresh": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.0.0.tgz", diff --git a/api/package.json b/api/package.json index 8824e0166..df0bce112 100644 --- a/api/package.json +++ b/api/package.json @@ -74,7 +74,6 @@ "fastify-jwt": "^0.3.0", "fastify-metrics": "^2.0.2", "fastify-swagger": "^0.15.0", - "immer": "^2.1.3", "joi": "^14.3.1", "jsonwebtoken": "^8.5.0", "lodash.clonedeep": "^4.5.0", diff --git a/api/src/http_errors/bad_request.ts b/api/src/http_errors/bad_request.ts new file mode 100644 index 000000000..0c5eea909 --- /dev/null +++ b/api/src/http_errors/bad_request.ts @@ -0,0 +1,16 @@ +import Joi = require("joi"); +import { VError } from "verror"; + +import { Ctx } from "../lib/ctx"; + +interface Info { + ctx: Ctx; + requestPath: string; + validationResult: Joi.ValidationError; +} + +export class BadRequest extends VError { + constructor(info: Info) { + super({ name: "BadRequest", info }, `invalid request to ${info.requestPath}`); + } +} diff --git a/api/src/service/cache2.spec.ts b/api/src/service/cache2.spec.ts index 4c18093f8..3907d9558 100644 --- a/api/src/service/cache2.spec.ts +++ b/api/src/service/cache2.spec.ts @@ -182,7 +182,7 @@ describe("The cache updates", () => { // Apply events to existing cache const testAssignee = "shiba"; - const wfAssginedEvent = WorkflowitemAssigned.createEvent( + const wfAssignedEvent = WorkflowitemAssigned.createEvent( "http", "test", projectId, @@ -190,7 +190,7 @@ describe("The cache updates", () => { workflowitemId, testAssignee, ); - if (Result.isErr(wfAssginedEvent)) { + if (Result.isErr(wfAssignedEvent)) { return assert.fail(undefined, undefined, "Workflowitem assigned event failed"); } const wfCloseEvent = WorkflowitemClosed.createEvent( @@ -203,7 +203,7 @@ describe("The cache updates", () => { if (Result.isErr(wfCloseEvent)) { return assert.fail(undefined, undefined, "Workflowitem closed event failed"); } - updateAggregates(defaultCtx, cache, [wfAssginedEvent, wfCloseEvent]); + updateAggregates(defaultCtx, cache, [wfAssignedEvent, wfCloseEvent]); // Test if events have been reflected on the aggregate const wfUnderTest = cache.cachedWorkflowItems.get(workflowitemId); diff --git a/api/src/service/cache2.ts b/api/src/service/cache2.ts index 88fa6eae1..7aeac1047 100644 --- a/api/src/service/cache2.ts +++ b/api/src/service/cache2.ts @@ -1,5 +1,4 @@ import { Ctx } from "../lib/ctx"; -import deepcopy from "../lib/deepcopy"; import { isEmpty } from "../lib/emptyChecks"; import logger from "../lib/logger"; import * as Result from "../result"; @@ -31,7 +30,6 @@ import * as SubprojectAssigned from "./domain/workflow/subproject_assigned"; import * as SubprojectClosed from "./domain/workflow/subproject_closed"; import * as SubprojectCreated from "./domain/workflow/subproject_created"; import { sourceSubprojects } from "./domain/workflow/subproject_eventsourcing"; -import * as WorkflowitemsReordered from "./domain/workflow/workflowitems_reordered"; import * as SubprojectPermissionsGranted from "./domain/workflow/subproject_permission_granted"; import * as SubprojectPermissionsRevoked from "./domain/workflow/subproject_permission_revoked"; import * as SubprojectProjectedBudgetDeleted from "./domain/workflow/subproject_projected_budget_deleted"; @@ -45,6 +43,7 @@ import { sourceWorkflowitems } from "./domain/workflow/workflowitem_eventsourcin import * as WorkflowitemPermissionsGranted from "./domain/workflow/workflowitem_permission_granted"; import * as WorkflowitemPermissionsRevoked from "./domain/workflow/workflowitem_permission_revoked"; import * as WorkflowitemUpdated from "./domain/workflow/workflowitem_updated"; +import * as WorkflowitemsReordered from "./domain/workflow/workflowitems_reordered"; import { Item } from "./liststreamitems"; const STREAM_BLACKLIST = [ @@ -117,17 +116,17 @@ export type TransactionFn = (cache: CacheInstance) => Promise; export function getCacheInstance(ctx: Ctx, cache: Cache2): CacheInstance { return { getGlobalEvents: (): BusinessEvent[] => { - return deepcopy(cache.eventsByStream.get("global")) || []; + return cache.eventsByStream.get("global") || []; }, getUserEvents: (_userId?: string): BusinessEvent[] => { // userId currently not leveraged - return deepcopy(cache.eventsByStream.get("users")) || []; + return cache.eventsByStream.get("users") || []; }, getGroupEvents: (_groupId?: string): BusinessEvent[] => { // groupId currently not leveraged - return deepcopy(cache.eventsByStream.get("groups")) || []; + return cache.eventsByStream.get("groups") || []; }, getNotificationEvents: (userId: string): BusinessEvent[] => { @@ -147,11 +146,11 @@ export function getCacheInstance(ctx: Ctx, cache: Cache2): CacheInstance { } }; - return (deepcopy(cache.eventsByStream.get("notifications")) || []).filter(userFilter); + return (cache.eventsByStream.get("notifications") || []).filter(userFilter); }, getProjects: async (): Promise => { - return deepcopy([...cache.cachedProjects.values()]); + return [...cache.cachedProjects.values()]; }, getProject: async (projectId: string): Promise> => { @@ -160,7 +159,7 @@ export function getCacheInstance(ctx: Ctx, cache: Cache2): CacheInstance { if (project === undefined) { return new NotFound(ctx, "project", projectId); } - return deepcopy(project); + return project; }, getSubprojects: async (projectId: string): Promise> => { @@ -180,7 +179,7 @@ export function getCacheInstance(ctx: Ctx, cache: Cache2): CacheInstance { } subprojects.push(sp); } - return deepcopy(subprojects); + return subprojects; }, getSubproject: ( @@ -191,7 +190,7 @@ export function getCacheInstance(ctx: Ctx, cache: Cache2): CacheInstance { if (subproject === undefined) { return new NotFound(ctx, "subproject", subprojectId); } - return deepcopy(subproject); + return subproject; }, getWorkflowitems: async ( @@ -213,7 +212,7 @@ export function getCacheInstance(ctx: Ctx, cache: Cache2): CacheInstance { } workflowitems.push(wf); } - return deepcopy(workflowitems); + return workflowitems; }, getWorkflowitem: async ( @@ -225,7 +224,7 @@ export function getCacheInstance(ctx: Ctx, cache: Cache2): CacheInstance { if (workflowitem === undefined) { return new NotFound(ctx, "workflowitem", workflowitemId); } - return deepcopy(workflowitem); + return workflowitem; }, }; } diff --git a/api/src/service/domain/workflow/project_assign.ts b/api/src/service/domain/workflow/project_assign.ts index c8ae2a3b6..5af1ed8a8 100644 --- a/api/src/service/domain/workflow/project_assign.ts +++ b/api/src/service/domain/workflow/project_assign.ts @@ -1,5 +1,5 @@ -import { produce } from "immer"; import { VError } from "verror"; + import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; @@ -12,6 +12,7 @@ import * as UserRecord from "../organization/user_record"; import * as NotificationCreated from "./notification_created"; import * as Project from "./project"; import * as ProjectAssigned from "./project_assigned"; +import * as ProjectEventSourcing from "./project_eventsourcing"; interface Repository { getProject(): Promise>; @@ -48,7 +49,7 @@ export async function assignProject( } // Check that the new event is indeed valid: - const result = produce(project, draft => ProjectAssigned.apply(ctx, projectAssigned, draft)); + const result = ProjectEventSourcing.newProjectFromEvent(ctx, project, projectAssigned); if (Result.isErr(result)) { return new InvalidCommand(ctx, projectAssigned, [result]); } diff --git a/api/src/service/domain/workflow/project_assigned.ts b/api/src/service/domain/workflow/project_assigned.ts index fdf8484b8..69840aed6 100644 --- a/api/src/service/domain/workflow/project_assigned.ts +++ b/api/src/service/domain/workflow/project_assigned.ts @@ -1,9 +1,7 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; @@ -59,15 +57,22 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - project: Project.Project, -): Result.Type { - project.assignee = event.assignee; +/** + * Applies the event to the given project, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified project + * is automatically validated when obtained using + * `project_eventsourcing.ts`:`newProjectFromEvent`. + */ +export function mutate(project: Project.Project, event: Event): Result.Type { + if (event.type !== "project_assigned") { + throw new VError(`illegal event type: ${event.type}`); + } - return Result.mapErr( - Project.validate(project), - error => new EventSourcingError({ ctx, event, target: project }, error), - ); + // Since we cannot have any side effects here, the existance of a user is expected to + // be validated before the event is produced. + project.assignee = event.assignee; } diff --git a/api/src/service/domain/workflow/project_close.ts b/api/src/service/domain/workflow/project_close.ts index 079499739..c70fb6923 100644 --- a/api/src/service/domain/workflow/project_close.ts +++ b/api/src/service/domain/workflow/project_close.ts @@ -1,5 +1,5 @@ -import { produce } from "immer"; import { VError } from "verror"; + import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; @@ -13,6 +13,7 @@ import * as UserRecord from "../organization/user_record"; import * as NotificationCreated from "./notification_created"; import * as Project from "./project"; import * as ProjectClosed from "./project_closed"; +import * as ProjectEventSourcing from "./project_eventsourcing"; import * as Subproject from "./subproject"; interface Repository { @@ -64,11 +65,11 @@ export async function closeProject( } // Check that the new event is indeed valid: - const result = produce(project, draft => ProjectClosed.apply(ctx, projectClosed, draft)); - if (Result.isErr(result)) { - return new InvalidCommand(ctx, projectClosed, [result]); + const validationResult = ProjectEventSourcing.newProjectFromEvent(ctx, project, projectClosed); + if (Result.isErr(validationResult)) { + return new InvalidCommand(ctx, projectClosed, [validationResult]); } - project = result; + project = validationResult; // Create notification events: const recipients = project.assignee ? await repository.getUsersForIdentity(project.assignee) : []; diff --git a/api/src/service/domain/workflow/project_closed.ts b/api/src/service/domain/workflow/project_closed.ts index 98f58807c..c3f2837df 100644 --- a/api/src/service/domain/workflow/project_closed.ts +++ b/api/src/service/domain/workflow/project_closed.ts @@ -1,9 +1,7 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; @@ -55,15 +53,20 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - project: Project.Project, -): Result.Type { - project.status = "closed"; +/** + * Applies the event to the given project, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified project + * is automatically validated when obtained using + * `project_eventsourcing.ts`:`newProjectFromEvent`. + */ +export function mutate(project: Project.Project, event: Event): Result.Type { + if (event.type !== "project_closed") { + throw new VError(`illegal event type: ${event.type}`); + } - return Result.mapErr( - Project.validate(project), - error => new EventSourcingError({ ctx, event, target: project }, error), - ); + project.status = "closed"; } diff --git a/api/src/service/domain/workflow/project_create.ts b/api/src/service/domain/workflow/project_create.ts index 063c5a33d..133292cc6 100644 --- a/api/src/service/domain/workflow/project_create.ts +++ b/api/src/service/domain/workflow/project_create.ts @@ -14,7 +14,6 @@ import { Permissions } from "../permissions"; import * as GlobalPermissions from "./global_permissions"; import * as Project from "./project"; import * as ProjectCreated from "./project_created"; -import { sourceProjects } from "./project_eventsourcing"; import { ProjectedBudget, projectedBudgetListSchema } from "./projected_budget"; /** @@ -106,10 +105,10 @@ export async function createProject( } } - // Check that the event is valid by trying to "apply" it: - const { errors } = sourceProjects(ctx, [createEvent]); - if (errors.length > 0) { - return { newEvents: [], errors: [new InvalidCommand(ctx, createEvent, errors)] }; + // Check that the event is valid: + const result = ProjectCreated.createFrom(ctx, createEvent); + if (Result.isErr(result)) { + return { newEvents: [], errors: [new InvalidCommand(ctx, createEvent, [result])] }; } return { newEvents: [createEvent], errors: [] }; diff --git a/api/src/service/domain/workflow/project_eventsourcing.ts b/api/src/service/domain/workflow/project_eventsourcing.ts index dcd7bbb65..1f393508d 100644 --- a/api/src/service/domain/workflow/project_eventsourcing.ts +++ b/api/src/service/domain/workflow/project_eventsourcing.ts @@ -1,5 +1,7 @@ -import { produce } from "immer"; +import { VError } from "verror"; + import { Ctx } from "../../../lib/ctx"; +import deepcopy from "../../../lib/deepcopy"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; import { EventSourcingError } from "../errors/event_sourcing_error"; @@ -19,100 +21,175 @@ export function sourceProjects( events: BusinessEvent[], origin?: Map, ): { projects: Project.Project[]; errors: Error[] } { - const projectsMap = + const projects = origin === undefined ? new Map() : new Map(origin); const errors: Error[] = []; + for (const event of events) { if (!event.type.startsWith("project_")) { continue; } - const result = applyProjectEvent(ctx, projectsMap, event); - if (Result.isErr(result)) { - errors.push(result); + const project = sourceEvent(ctx, event, projects); + if (Result.isErr(project)) { + errors.push(project); } else { - result.log.push(newTraceEvent(result, event)); - projectsMap.set(result.id, result); + project.log.push(newTraceEvent(project, event)); + projects.set(project.id, project); } } - const projects = [...projectsMap.values()]; - return { projects, errors }; + + return { projects: [...projects.values()], errors }; } -function applyProjectEvent( +function newTraceEvent(project: Project.Project, event: BusinessEvent): ProjectTraceEvent { + return { + entityId: project.id, + entityType: "project", + businessEvent: event, + snapshot: { + displayName: project.displayName, + }, + }; +} + +function sourceEvent( ctx: Ctx, - projects: Map, event: BusinessEvent, + projects: Map, +): Result.Type { + const projectId = getProjectId(event); + let project: Result.Type; + if (Result.isOk(projectId)) { + // The event refers to an existing project, so + // the project should have been initialized already. + + project = get(projects, projectId); + if (Result.isErr(project)) { + return new VError(`project ID ${projectId} found in event ${event.type} is invalid`); + } + + project = newProjectFromEvent(ctx, project, event); + if (Result.isErr(project)) { + return project; // <- event-sourcing error + } + } else { + // The event does not refer to a project ID, so it must be a creation event: + if (event.type !== "project_created") { + return new VError( + `event ${event.type} is not of type "project_created" but also ` + + "does not include a project ID", + ); + } + + project = ProjectCreated.createFrom(ctx, event); + if (Result.isErr(project)) { + return new VError(project, "could not create project from event"); + } + } + + return project; +} + +function get( + projects: Map, + projectId: Project.Id, ): Result.Type { + const project = projects.get(projectId); + if (project === undefined) { + return new VError(`project ${projectId} not yet initialized`); + } + return project; +} + +function getProjectId(event: BusinessEvent): Result.Type { switch (event.type) { - case "project_created": - return ProjectCreated.createFrom(ctx, event); + case "project_updated": + case "project_assigned": + case "project_closed": + case "project_permission_granted": + case "project_permission_revoked": + case "project_projected_budget_updated": + case "project_projected_budget_deleted": + return event.projectId; + + default: + return new VError(`cannot find project ID in event of type ${event.type}`); + } +} +/** Returns a new project with the given event applied, or an error. */ +export function newProjectFromEvent( + ctx: Ctx, + project: Project.Project, + event: BusinessEvent, +): Result.Type { + const eventModule = getEventModule(event); + + // Ensure that we never modify project or event in-place by passing copies. When + // copying the project, its event log is omitted for performance reasons. + const eventCopy = deepcopy(event); + const projectCopy = copyProjectExceptLog(project); + + try { + // Apply the event to the copied project: + const mutation = eventModule.mutate(projectCopy, eventCopy); + if (Result.isErr(mutation)) { + throw mutation; + } + + // Validate the modified project: + const validation = Project.validate(projectCopy); + if (Result.isErr(validation)) { + throw validation; + } + + // Restore the event log: + projectCopy.log = project.log; + + // Return the modified (and validated) project: + return projectCopy; + } catch (error) { + return new EventSourcingError({ ctx, event, target: project }, error); + } +} + +type EventModule = { + mutate: (project: Project.Project, event: BusinessEvent) => Result.Type; +}; +function getEventModule(event: BusinessEvent): EventModule { + switch (event.type) { case "project_updated": - return apply(ctx, event, projects, event.projectId, ProjectUpdated); + return ProjectUpdated; case "project_assigned": - return apply(ctx, event, projects, event.projectId, ProjectAssigned); + return ProjectAssigned; case "project_closed": - return apply(ctx, event, projects, event.projectId, ProjectClosed); + return ProjectClosed; case "project_permission_granted": - return apply(ctx, event, projects, event.projectId, ProjectPermissionGranted); + return ProjectPermissionGranted; case "project_permission_revoked": - return apply(ctx, event, projects, event.projectId, ProjectPermissionRevoked); + return ProjectPermissionRevoked; case "project_projected_budget_updated": - return apply(ctx, event, projects, event.projectId, ProjectProjectedBudgetUpdated); + return ProjectProjectedBudgetUpdated; case "project_projected_budget_deleted": - return apply(ctx, event, projects, event.projectId, ProjectProjectedBudgetDeleted); + return ProjectProjectedBudgetDeleted; default: - throw Error(`not implemented: ${event.type}`); + throw new VError(`unknown project event ${event.type}`); } } -function newTraceEvent(project: Project.Project, event: BusinessEvent): ProjectTraceEvent { - return { - entityId: project.id, - entityType: "project", - businessEvent: event, - snapshot: { - displayName: project.displayName, - }, - }; -} - -type ApplyFn = ( - ctx: Ctx, - event: BusinessEvent, - project: Project.Project, -) => Result.Type; -function apply( - ctx: Ctx, - event: BusinessEvent, - projects: Map, - projectId: string, - eventModule: { apply: ApplyFn }, -) { - const project = projects.get(projectId); - if (project === undefined) { - return new EventSourcingError({ ctx, event, target: { projectId } }, "not found"); - } - - try { - return produce(project, draft => { - const result = eventModule.apply(ctx, event, draft); - if (Result.isErr(result)) { - throw result; - } - return result; - }); - } catch (err) { - return err; - } +function copyProjectExceptLog(project: Project.Project): Project.Project { + const { log, ...tmp } = project; + const copy = deepcopy(tmp); + (copy as any).log = []; + return copy as Project.Project; } diff --git a/api/src/service/domain/workflow/project_permission_grant.ts b/api/src/service/domain/workflow/project_permission_grant.ts index e0c5200e6..e2354edfb 100644 --- a/api/src/service/domain/workflow/project_permission_grant.ts +++ b/api/src/service/domain/workflow/project_permission_grant.ts @@ -1,6 +1,5 @@ import isEqual = require("lodash.isequal"); -import { produce } from "immer"; import Intent from "../../../authz/intents"; import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; @@ -11,6 +10,7 @@ import { NotFound } from "../errors/not_found"; import { Identity } from "../organization/identity"; import { ServiceUser } from "../organization/service_user"; import * as Project from "./project"; +import * as ProjectEventSourcing from "./project_eventsourcing"; import * as ProjectPermissionGranted from "./project_permission_granted"; interface Repository { @@ -49,9 +49,7 @@ export async function grantProjectPermission( } // Check that the new event is indeed valid: - const updatedProject = produce(project, draft => - ProjectPermissionGranted.apply(ctx, permissionGranted, draft), - ); + const updatedProject = ProjectEventSourcing.newProjectFromEvent(ctx, project, permissionGranted); if (Result.isErr(updatedProject)) { return new InvalidCommand(ctx, permissionGranted, [updatedProject]); } diff --git a/api/src/service/domain/workflow/project_permission_granted.ts b/api/src/service/domain/workflow/project_permission_granted.ts index 07f4aee4e..3b3fbdc8f 100644 --- a/api/src/service/domain/workflow/project_permission_granted.ts +++ b/api/src/service/domain/workflow/project_permission_granted.ts @@ -2,9 +2,7 @@ import Joi = require("joi"); import { VError } from "verror"; import Intent, { projectIntents } from "../../../authz/intents"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; @@ -64,20 +62,25 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - project: Project.Project, -): Result.Type { +/** + * Applies the event to the given project, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified project + * is automatically validated when obtained using + * `project_eventsourcing.ts`:`newProjectFromEvent`. + */ +export function mutate(project: Project.Project, event: Event): Result.Type { + if (event.type !== "project_permission_granted") { + throw new VError(`illegal event type: ${event.type}`); + } + const eligibleIdentities = project.permissions[event.permission] || []; if (!eligibleIdentities.includes(event.grantee)) { eligibleIdentities.push(event.grantee); } project.permissions[event.permission] = eligibleIdentities; - - return Result.mapErr( - Project.validate(project), - error => new EventSourcingError({ ctx, event, target: project }, error), - ); } diff --git a/api/src/service/domain/workflow/project_permission_revoke.ts b/api/src/service/domain/workflow/project_permission_revoke.ts index 01be5d666..2541c40a3 100644 --- a/api/src/service/domain/workflow/project_permission_revoke.ts +++ b/api/src/service/domain/workflow/project_permission_revoke.ts @@ -1,6 +1,5 @@ import isEqual = require("lodash.isequal"); -import { produce } from "immer"; import Intent from "../../../authz/intents"; import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; @@ -11,6 +10,7 @@ import { NotFound } from "../errors/not_found"; import { Identity } from "../organization/identity"; import { ServiceUser } from "../organization/service_user"; import * as Project from "./project"; +import * as ProjectEventSourcing from "./project_eventsourcing"; import * as ProjectPermissionRevoked from "./project_permission_revoked"; interface Repository { @@ -49,9 +49,7 @@ export async function revokeProjectPermission( } // Check that the new event is indeed valid: - const updatedProject = produce(project, draft => - ProjectPermissionRevoked.apply(ctx, permissionRevoked, draft), - ); + const updatedProject = ProjectEventSourcing.newProjectFromEvent(ctx, project, permissionRevoked); if (Result.isErr(updatedProject)) { return new InvalidCommand(ctx, permissionRevoked, [updatedProject]); } diff --git a/api/src/service/domain/workflow/project_permission_revoked.ts b/api/src/service/domain/workflow/project_permission_revoked.ts index 7f0198958..9efb8b0b5 100644 --- a/api/src/service/domain/workflow/project_permission_revoked.ts +++ b/api/src/service/domain/workflow/project_permission_revoked.ts @@ -2,9 +2,7 @@ import Joi = require("joi"); import { VError } from "verror"; import Intent, { projectIntents } from "../../../authz/intents"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; @@ -64,15 +62,25 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - project: Project.Project, -): Result.Type { +/** + * Applies the event to the given project, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified project + * is automatically validated when obtained using + * `project_eventsourcing.ts`:`newProjectFromEvent`. + */ +export function mutate(project: Project.Project, event: Event): Result.Type { + if (event.type !== "project_permission_revoked") { + throw new VError(`illegal event type: ${event.type}`); + } + const eligibleIdentities = project.permissions[event.permission]; if (eligibleIdentities === undefined) { // Nothing to do here.. - return project; + return; } const foundIndex = eligibleIdentities.indexOf(event.revokee); @@ -81,11 +89,4 @@ export function apply( // Remove the user from the array: eligibleIdentities.splice(foundIndex, 1); } - - project.permissions[event.permission] = eligibleIdentities; - - return Result.mapErr( - Project.validate(project), - error => new EventSourcingError({ ctx, event, target: project }, error), - ); } diff --git a/api/src/service/domain/workflow/project_projected_budget_delete.ts b/api/src/service/domain/workflow/project_projected_budget_delete.ts index 566785b2e..e8f3283ae 100644 --- a/api/src/service/domain/workflow/project_projected_budget_delete.ts +++ b/api/src/service/domain/workflow/project_projected_budget_delete.ts @@ -1,3 +1,5 @@ +import { isEqual } from "lodash"; + import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; @@ -6,8 +8,9 @@ import { NotAuthorized } from "../errors/not_authorized"; import { NotFound } from "../errors/not_found"; import { ServiceUser } from "../organization/service_user"; import * as Project from "./project"; -import { ProjectedBudget } from "./projected_budget"; +import * as ProjectEventSourcing from "./project_eventsourcing"; import * as ProjectProjectedBudgetDeleted from "./project_projected_budget_deleted"; +import { ProjectedBudget } from "./projected_budget"; interface Repository { getProject(projectId: Project.Id): Promise>; @@ -22,7 +25,7 @@ export async function deleteProjectedBudget( organization: string, currencyCode: string, repository: Repository, -): Promise> { +): Promise> { const project = await repository.getProject(projectId); if (Result.isErr(project)) { @@ -47,13 +50,18 @@ export async function deleteProjectedBudget( } // Check that the new event is indeed valid: - const result = ProjectProjectedBudgetDeleted.apply(ctx, budgetDeleted, project); + const result = ProjectEventSourcing.newProjectFromEvent(ctx, project, budgetDeleted); if (Result.isErr(result)) { return new InvalidCommand(ctx, budgetDeleted, [result]); } - return { - newEvents: [budgetDeleted], - newState: result.projectedBudgets, - }; + // Only emit the event if it causes any changes: + if (isEqual(project.projectedBudgets, result.projectedBudgets)) { + return { newEvents: [], projectedBudgets: result.projectedBudgets }; + } else { + return { + newEvents: [budgetDeleted], + projectedBudgets: result.projectedBudgets, + }; + } } diff --git a/api/src/service/domain/workflow/project_projected_budget_deleted.ts b/api/src/service/domain/workflow/project_projected_budget_deleted.ts index 14607ae67..52cefc29f 100644 --- a/api/src/service/domain/workflow/project_projected_budget_deleted.ts +++ b/api/src/service/domain/workflow/project_projected_budget_deleted.ts @@ -1,9 +1,7 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import { CurrencyCode, currencyCodeSchema } from "./money"; import * as Project from "./project"; @@ -64,22 +62,26 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - project: Project.Project, -): Result.Type { +/** + * Applies the event to the given project, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified project + * is automatically validated when obtained using + * `project_eventsourcing.ts`:`newProjectFromEvent`. + */ +export function mutate(project: Project.Project, event: Event): Result.Type { + if (event.type !== "project_projected_budget_deleted") { + throw new VError(`illegal event type: ${event.type}`); + } + // An organization may have multiple budgets, but any two budgets of the same // organization always have a different currency. The reasoning: if an organization // makes two financial commitments in the same currency, they can represented by one // commitment with the same currency and the sum of both commitments as its value. - const projectedBudgets = project.projectedBudgets.filter( - x => x.organization === event.organization && x.currencyCode === event.currencyCode, - ); - project = { ...project, projectedBudgets }; - - return Result.mapErr( - Project.validate(project), - error => new EventSourcingError({ ctx, event, target: project }, error), + project.projectedBudgets = project.projectedBudgets.filter( + x => !(x.organization === event.organization && x.currencyCode === event.currencyCode), ); } diff --git a/api/src/service/domain/workflow/project_projected_budget_update.ts b/api/src/service/domain/workflow/project_projected_budget_update.ts index 97fcee957..f9024bbce 100644 --- a/api/src/service/domain/workflow/project_projected_budget_update.ts +++ b/api/src/service/domain/workflow/project_projected_budget_update.ts @@ -1,4 +1,5 @@ -import { produce } from "immer"; +import { isEqual } from "lodash"; + import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; @@ -7,8 +8,9 @@ import { NotAuthorized } from "../errors/not_authorized"; import { NotFound } from "../errors/not_found"; import { ServiceUser } from "../organization/service_user"; import * as Project from "./project"; -import { ProjectedBudget } from "./projected_budget"; +import * as ProjectEventSourcing from "./project_eventsourcing"; import * as ProjectProjectedBudgetUpdated from "./project_projected_budget_updated"; +import { ProjectedBudget } from "./projected_budget"; interface Repository { getProject(projectId: Project.Id): Promise>; @@ -24,7 +26,7 @@ export async function updateProjectedBudget( value: string, currencyCode: string, repository: Repository, -): Promise> { +): Promise> { const project = await repository.getProject(projectId); if (Result.isErr(project)) { @@ -50,17 +52,18 @@ export async function updateProjectedBudget( } // Check that the new event is indeed valid: - - const result = produce(project, draft => - ProjectProjectedBudgetUpdated.apply(ctx, budgetUpdated, draft), - ); - + const result = ProjectEventSourcing.newProjectFromEvent(ctx, project, budgetUpdated); if (Result.isErr(result)) { return new InvalidCommand(ctx, budgetUpdated, [result]); } - return { - newEvents: [budgetUpdated], - newState: result.projectedBudgets, - }; + // Only emit the event if it causes any changes: + if (isEqual(project.projectedBudgets, result.projectedBudgets)) { + return { newEvents: [], projectedBudgets: result.projectedBudgets }; + } else { + return { + newEvents: [budgetUpdated], + projectedBudgets: result.projectedBudgets, + }; + } } diff --git a/api/src/service/domain/workflow/project_projected_budget_updated.ts b/api/src/service/domain/workflow/project_projected_budget_updated.ts index 9854709e3..85d1deeec 100644 --- a/api/src/service/domain/workflow/project_projected_budget_updated.ts +++ b/api/src/service/domain/workflow/project_projected_budget_updated.ts @@ -1,9 +1,7 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import { CurrencyCode, currencyCodeSchema, MoneyAmount, moneyAmountSchema } from "./money"; import * as Project from "./project"; @@ -68,11 +66,21 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - project: Project.Project, -): Result.Type { +/** + * Applies the event to the given project, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified project + * is automatically validated when obtained using + * `project_eventsourcing.ts`:`newProjectFromEvent`. + */ +export function mutate(project: Project.Project, event: Event): Result.Type { + if (event.type !== "project_projected_budget_updated") { + throw new VError(`illegal event type: ${event.type}`); + } + // An organization may have multiple budgets, but any two budgets of the same // organization always have a different currency. The reasoning: if an organization // makes two financial commitments in the same currency, they can represented by one @@ -93,11 +101,4 @@ export function apply( value: event.value, }); } - - project = { ...project, projectedBudgets }; - - return Result.mapErr( - Project.validate(project), - error => new EventSourcingError({ ctx, event, target: project }, error), - ); } diff --git a/api/src/service/domain/workflow/project_update.ts b/api/src/service/domain/workflow/project_update.ts index 4cd15fbdb..05d79cabb 100644 --- a/api/src/service/domain/workflow/project_update.ts +++ b/api/src/service/domain/workflow/project_update.ts @@ -1,8 +1,8 @@ import Joi = require("joi"); +import { isEqual } from "lodash"; import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import * as AdditionalData from "../additional_data"; import { BusinessEvent } from "../business_event"; import { InvalidCommand } from "../errors/invalid_command"; import { NotAuthorized } from "../errors/not_authorized"; @@ -12,6 +12,7 @@ import { ServiceUser } from "../organization/service_user"; import * as UserRecord from "../organization/user_record"; import * as NotificationCreated from "./notification_created"; import * as Project from "./project"; +import * as ProjectEventSourcing from "./project_eventsourcing"; import * as ProjectUpdated from "./project_updated"; export type RequestData = ProjectUpdated.Modification; @@ -52,12 +53,16 @@ export async function updateProject( } // Check that the new event is indeed valid: - - const result = ProjectUpdated.apply(ctx, projectUpdated, project); + const result = ProjectEventSourcing.newProjectFromEvent(ctx, project, projectUpdated); if (Result.isErr(result)) { return new InvalidCommand(ctx, projectUpdated, [result]); } + // Only emit the event if it causes any changes: + if (isEqualIgnoringLog(project, result)) { + return { newEvents: [] }; + } + // Create notification events: let notifications: NotificationCreated.Event[] = []; if (project.assignee !== undefined && project.assignee !== issuer.id) { @@ -69,3 +74,9 @@ export async function updateProject( return { newEvents: [projectUpdated, ...notifications] }; } + +function isEqualIgnoringLog(projectA: Project.Project, projectB: Project.Project): boolean { + const { log: logA, ...a } = projectA; + const { log: logB, ...b } = projectB; + return isEqual(a, b); +} diff --git a/api/src/service/domain/workflow/project_updated.ts b/api/src/service/domain/workflow/project_updated.ts index 98f2d0227..c240dac85 100644 --- a/api/src/service/domain/workflow/project_updated.ts +++ b/api/src/service/domain/workflow/project_updated.ts @@ -1,13 +1,10 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import * as AdditionalData from "../additional_data"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; -import deepcopy from "../../../lib/deepcopy"; type eventTypeType = "project_updated"; const eventType: eventTypeType = "project_updated"; @@ -81,40 +78,36 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - project: Project.Project, -): Result.Type { +/** + * Applies the event to the given project, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified project + * is automatically validated when obtained using + * `project_eventsourcing.ts`:`newProjectFromEvent`. + */ +export function mutate(project: Project.Project, event: Event): Result.Type { + if (event.type !== "project_updated") { + throw new VError(`illegal event type: ${event.type}`); + } + if (project.status !== "open") { - return new EventSourcingError( - { ctx, event, target: project }, - `a project may only be updated if its status is "open"`, - ); + return new VError('a project may only be updated if its status is "open"'); } const update = event.update; - const additionalData = project.additionalData; + ["displayName", "description", "thumbnail"].forEach(propname => { + if (update[propname] !== undefined) { + project[propname] = update[propname]; + } + }); + if (update.additionalData) { for (const key of Object.keys(update.additionalData)) { - additionalData[key] = update.additionalData[key]; + project.additionalData[key] = update.additionalData[key]; } } - - const nextState = { - ...project, - // Only updated if defined in the `update`: - ...(update.displayName !== undefined && { displayName: update.displayName }), - // Only updated if defined in the `update`: - ...(update.description !== undefined && { description: update.description }), - // Only updated if defined in the `update`: - ...(update.thumbnail !== undefined && { thumbnail: update.thumbnail }), - additionalData, - }; - - return Result.mapErr( - Project.validate(nextState), - error => new EventSourcingError({ ctx, event, target: project }, error), - ); } diff --git a/api/src/service/domain/workflow/subproject_assign.ts b/api/src/service/domain/workflow/subproject_assign.ts index 6d7785ca4..e236a83ad 100644 --- a/api/src/service/domain/workflow/subproject_assign.ts +++ b/api/src/service/domain/workflow/subproject_assign.ts @@ -1,5 +1,5 @@ -import { produce } from "immer"; import { VError } from "verror"; + import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; @@ -13,6 +13,7 @@ import * as NotificationCreated from "./notification_created"; import * as Project from "./project"; import * as Subproject from "./subproject"; import * as SubprojectAssigned from "./subproject_assigned"; +import * as SubprojectEventSourcing from "./subproject_eventsourcing"; interface Repository { getSubproject(): Promise>; @@ -58,8 +59,10 @@ export async function assignSubproject( } // Check that the new event is indeed valid: - const result = produce(subproject, draft => - SubprojectAssigned.apply(ctx, subprojectAssigned, draft), + const result = SubprojectEventSourcing.newSubprojectFromEvent( + ctx, + subproject, + subprojectAssigned, ); if (Result.isErr(result)) { return new InvalidCommand(ctx, subprojectAssigned, [result]); diff --git a/api/src/service/domain/workflow/subproject_assigned.ts b/api/src/service/domain/workflow/subproject_assigned.ts index 50fd60378..309dd527b 100644 --- a/api/src/service/domain/workflow/subproject_assigned.ts +++ b/api/src/service/domain/workflow/subproject_assigned.ts @@ -1,9 +1,7 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; import * as Subproject from "./subproject"; @@ -65,15 +63,22 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - subproject: Subproject.Subproject, -): Result.Type { - const newState: Subproject.Subproject = { ...subproject, assignee: event.assignee }; +/** + * Applies the event to the given subproject, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * subproject is automatically validated when obtained using + * `subproject_eventsourcing.ts`:`newSubprojectFromEvent`. + */ +export function mutate(subproject: Subproject.Subproject, event: Event): Result.Type { + if (event.type !== "subproject_assigned") { + throw new VError(`illegal event type: ${event.type}`); + } - return Result.mapErr( - Subproject.validate(newState), - error => new EventSourcingError({ ctx, event, target: subproject }, error), - ); + // Since we cannot have any side effects here, the existance of a user is expected to + // be validated before the event is produced. + subproject.assignee = event.assignee; } diff --git a/api/src/service/domain/workflow/subproject_close.ts b/api/src/service/domain/workflow/subproject_close.ts index aef8fae18..c0f969ccb 100644 --- a/api/src/service/domain/workflow/subproject_close.ts +++ b/api/src/service/domain/workflow/subproject_close.ts @@ -1,5 +1,5 @@ -import { produce } from "immer"; import { VError } from "verror"; + import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; @@ -14,6 +14,7 @@ import * as NotificationCreated from "./notification_created"; import * as Project from "./project"; import * as Subproject from "./subproject"; import * as SubprojectClosed from "./subproject_closed"; +import * as SubprojectEventSourcing from "./subproject_eventsourcing"; import * as Workflowitem from "./workflowitem"; interface Repository { @@ -82,7 +83,7 @@ export async function closeSubproject( } // Check that the new event is indeed valid: - const result = produce(subproject, draft => SubprojectClosed.apply(ctx, subprojectClosed, draft)); + const result = SubprojectEventSourcing.newSubprojectFromEvent(ctx, subproject, subprojectClosed); if (Result.isErr(result)) { return new InvalidCommand(ctx, subprojectClosed, [result]); } diff --git a/api/src/service/domain/workflow/subproject_closed.ts b/api/src/service/domain/workflow/subproject_closed.ts index df91122a8..1fed85755 100644 --- a/api/src/service/domain/workflow/subproject_closed.ts +++ b/api/src/service/domain/workflow/subproject_closed.ts @@ -1,9 +1,7 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; import * as Subproject from "./subproject"; @@ -60,15 +58,20 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - subproject: Subproject.Subproject, -): Result.Type { - const newState: Subproject.Subproject = { ...subproject, status: "closed" }; +/** + * Applies the event to the given subproject, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * subproject is automatically validated when obtained using + * `subproject_eventsourcing.ts`:`newSubprojectFromEvent`. + */ +export function mutate(subproject: Subproject.Subproject, event: Event): Result.Type { + if (event.type !== "subproject_closed") { + throw new VError(`illegal event type: ${event.type}`); + } - return Result.mapErr( - Subproject.validate(newState), - error => new EventSourcingError({ ctx, event, target: subproject }, error), - ); + subproject.status = "closed"; } diff --git a/api/src/service/domain/workflow/subproject_create.ts b/api/src/service/domain/workflow/subproject_create.ts index 43a76ca05..4c1330784 100644 --- a/api/src/service/domain/workflow/subproject_create.ts +++ b/api/src/service/domain/workflow/subproject_create.ts @@ -123,10 +123,10 @@ export async function createSubproject( } } - // Check that the event is valid by trying to "apply" it: - const { errors } = sourceSubprojects(ctx, [subprojectCreated]); - if (errors.length > 0) { - return new InvalidCommand(ctx, subprojectCreated, errors); + // Check that the event is valid: + const result = SubprojectCreated.createFrom(ctx, subprojectCreated); + if (Result.isErr(result)) { + return new InvalidCommand(ctx, subprojectCreated, [result]); } return { newEvents: [subprojectCreated] }; diff --git a/api/src/service/domain/workflow/subproject_eventsourcing.ts b/api/src/service/domain/workflow/subproject_eventsourcing.ts index 9304aa2bb..08e32f840 100644 --- a/api/src/service/domain/workflow/subproject_eventsourcing.ts +++ b/api/src/service/domain/workflow/subproject_eventsourcing.ts @@ -1,7 +1,7 @@ -import { produce } from "immer"; +import { VError } from "verror"; import { Ctx } from "../../../lib/ctx"; -import logger from "../../../lib/logger"; +import deepcopy from "../../../lib/deepcopy"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; import { EventSourcingError } from "../errors/event_sourcing_error"; @@ -9,13 +9,13 @@ import * as Subproject from "./subproject"; import * as SubprojectAssigned from "./subproject_assigned"; import * as SubprojectClosed from "./subproject_closed"; import * as SubprojectCreated from "./subproject_created"; -import * as WorkflowitemsReordered from "./workflowitems_reordered"; import * as SubprojectPermissionGranted from "./subproject_permission_granted"; import * as SubprojectPermissionRevoked from "./subproject_permission_revoked"; import * as SubprojectProjectedBudgetDeleted from "./subproject_projected_budget_deleted"; import * as SubprojectProjectedBudgetUpdated from "./subproject_projected_budget_updated"; import { SubprojectTraceEvent } from "./subproject_trace_event"; import * as SubprojectUpdated from "./subproject_updated"; +import * as WorkflowitemsReordered from "./workflowitems_reordered"; export function sourceSubprojects( ctx: Ctx, @@ -26,62 +26,25 @@ export function sourceSubprojects( origin === undefined ? new Map() : new Map(origin); - const errors: Error[] = []; + for (const event of events) { if (!event.type.startsWith("subproject_") && event.type !== "workflowitems_reordered") { continue; } - const result = applySubprojectEvent(ctx, subprojects, event); - if (Result.isErr(result)) { - errors.push(result); + + const subproject = sourceEvent(ctx, event, subprojects); + if (Result.isErr(subproject)) { + errors.push(subproject); } else { - result.log.push(newTraceEvent(result, event)); - subprojects.set(result.id, result); + subproject.log.push(newTraceEvent(subproject, event)); + subprojects.set(subproject.id, subproject); } } return { subprojects: [...subprojects.values()], errors }; } -function applySubprojectEvent( - ctx: Ctx, - subprojects: Map, - event: BusinessEvent, -): Result.Type { - switch (event.type) { - case "subproject_created": - return SubprojectCreated.createFrom(ctx, event); - - case "subproject_updated": - return apply(ctx, event, subprojects, event.subprojectId, SubprojectUpdated); - - case "subproject_assigned": - return apply(ctx, event, subprojects, event.subprojectId, SubprojectAssigned); - - case "subproject_closed": - return apply(ctx, event, subprojects, event.subprojectId, SubprojectClosed); - - case "workflowitems_reordered": - return apply(ctx, event, subprojects, event.subprojectId, WorkflowitemsReordered); - - case "subproject_permission_granted": - return apply(ctx, event, subprojects, event.subprojectId, SubprojectPermissionGranted); - - case "subproject_permission_revoked": - return apply(ctx, event, subprojects, event.subprojectId, SubprojectPermissionRevoked); - - case "subproject_projected_budget_updated": - return apply(ctx, event, subprojects, event.subprojectId, SubprojectProjectedBudgetUpdated); - - case "subproject_projected_budget_deleted": - return apply(ctx, event, subprojects, event.subprojectId, SubprojectProjectedBudgetDeleted); - - default: - return Error(`not implemented: ${event.type}`); - } -} - function newTraceEvent( subproject: Subproject.Subproject, event: BusinessEvent, @@ -96,33 +59,145 @@ function newTraceEvent( }; } -type ApplyFn = ( +function sourceEvent( ctx: Ctx, event: BusinessEvent, - subproject: Subproject.Subproject, -) => Result.Type; + subprojects: Map, +): Result.Type { + const subprojectId = getSubprojectId(event); + let subproject: Result.Type; + if (Result.isOk(subprojectId)) { + // The event refers to an existing subproject, so + // the subproject should have been initialized already. + + subproject = get(subprojects, subprojectId); + if (Result.isErr(subproject)) { + return new VError(`subproject ID ${subprojectId} found in event ${event.type} is invalid`); + } -export function apply( - ctx: Ctx, - event: BusinessEvent, + subproject = newSubprojectFromEvent(ctx, subproject, event); + if (Result.isErr(subproject)) { + return subproject; // <- event-sourcing error + } + } else { + // The event does not refer to a subproject ID, so it must be a creation event: + if (event.type !== "subproject_created") { + return new VError( + `event ${event.type} is not of type "subproject_created" but also ` + + "does not include a subproject ID", + ); + } + + subproject = SubprojectCreated.createFrom(ctx, event); + if (Result.isErr(subproject)) { + return new VError(subproject, "could not create subproject from event"); + } + } + + return subproject; +} + +function get( subprojects: Map, - subprojectId: string, - eventModule: { apply: ApplyFn }, -) { + subprojectId: Subproject.Id, +): Result.Type { const subproject = subprojects.get(subprojectId); if (subproject === undefined) { - return new EventSourcingError({ ctx, event, target: { subprojectId } }, "not found"); + return new VError(`subproject ${subprojectId} not yet initialized`); } + return subproject; +} + +function getSubprojectId(event: BusinessEvent): Result.Type { + switch (event.type) { + case "subproject_updated": + case "subproject_assigned": + case "subproject_closed": + case "subproject_permission_granted": + case "subproject_permission_revoked": + case "subproject_projected_budget_updated": + case "subproject_projected_budget_deleted": + case "workflowitems_reordered": + return event.subprojectId; + + default: + return new VError(`cannot find subproject ID in event of type ${event.type}`); + } +} + +/** Returns a new subproject with the given event applied, or an error. */ +export function newSubprojectFromEvent( + ctx: Ctx, + subproject: Subproject.Subproject, + event: BusinessEvent, +): Result.Type { + const eventModule = getEventModule(event); + + // Ensure that we never modify subproject or event in-place by passing copies. When + // copying the subproject, its event log is omitted for performance reasons. + const eventCopy = deepcopy(event); + const subprojectCopy = copySubprojectExceptLog(subproject); try { - return produce(subproject, draft => { - const result = eventModule.apply(ctx, event, draft); - if (Result.isErr(result)) { - throw result; - } - return result; - }); - } catch (err) { - return err; + // Apply the event to the copied subproject: + const mutation = eventModule.mutate(subprojectCopy, eventCopy); + if (Result.isErr(mutation)) { + throw mutation; + } + + // Validate the modified subproject: + const validation = Subproject.validate(subprojectCopy); + if (Result.isErr(validation)) { + throw validation; + } + + // Restore the event log: + subprojectCopy.log = subproject.log; + + // Return the modified (and validated) subproject: + return subprojectCopy; + } catch (error) { + return new EventSourcingError({ ctx, event, target: subproject }, error); } } + +type EventModule = { + mutate: (subproject: Subproject.Subproject, event: BusinessEvent) => Result.Type; +}; +function getEventModule(event: BusinessEvent): EventModule { + switch (event.type) { + case "subproject_updated": + return SubprojectUpdated; + + case "subproject_assigned": + return SubprojectAssigned; + + case "subproject_closed": + return SubprojectClosed; + + case "subproject_permission_granted": + return SubprojectPermissionGranted; + + case "subproject_permission_revoked": + return SubprojectPermissionRevoked; + + case "subproject_projected_budget_updated": + return SubprojectProjectedBudgetUpdated; + + case "subproject_projected_budget_deleted": + return SubprojectProjectedBudgetDeleted; + + case "workflowitems_reordered": + return WorkflowitemsReordered; + + default: + throw new VError(`unknown subproject event ${event.type}`); + } +} + +function copySubprojectExceptLog(subproject: Subproject.Subproject): Subproject.Subproject { + const { log, ...tmp } = subproject; + const copy = deepcopy(tmp); + (copy as any).log = []; + return copy as Subproject.Subproject; +} diff --git a/api/src/service/domain/workflow/subproject_permission_grant.ts b/api/src/service/domain/workflow/subproject_permission_grant.ts index 67ff45735..e4135afce 100644 --- a/api/src/service/domain/workflow/subproject_permission_grant.ts +++ b/api/src/service/domain/workflow/subproject_permission_grant.ts @@ -1,9 +1,7 @@ -import { produce } from "immer"; import isEqual = require("lodash.isequal"); import Intent from "../../../authz/intents"; import { Ctx } from "../../../lib/ctx"; -import logger from "../../../lib/logger"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; import { InvalidCommand } from "../errors/invalid_command"; @@ -13,6 +11,7 @@ import { Identity } from "../organization/identity"; import { ServiceUser } from "../organization/service_user"; import * as Project from "./project"; import * as Subproject from "./subproject"; +import * as SubprojectEventSourcing from "./subproject_eventsourcing"; import * as SubprojectPermissionGranted from "./subproject_permission_granted"; interface Repository { @@ -56,8 +55,10 @@ export async function grantSubprojectPermission( } // Check that the new event is indeed valid: - const updatedSubproject = produce(subproject, draft => - SubprojectPermissionGranted.apply(ctx, permissionGranted, draft), + const updatedSubproject = SubprojectEventSourcing.newSubprojectFromEvent( + ctx, + subproject, + permissionGranted, ); if (Result.isErr(updatedSubproject)) { return new InvalidCommand(ctx, permissionGranted, [updatedSubproject]); diff --git a/api/src/service/domain/workflow/subproject_permission_granted.ts b/api/src/service/domain/workflow/subproject_permission_granted.ts index de47f79a3..ec6965224 100644 --- a/api/src/service/domain/workflow/subproject_permission_granted.ts +++ b/api/src/service/domain/workflow/subproject_permission_granted.ts @@ -2,9 +2,7 @@ import Joi = require("joi"); import { VError } from "verror"; import Intent, { subprojectIntents } from "../../../authz/intents"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; import * as Subproject from "./subproject"; @@ -70,20 +68,25 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - subproject: Subproject.Subproject, -): Result.Type { +/** + * Applies the event to the given subproject, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * subproject is automatically validated when obtained using + * `subproject_eventsourcing.ts`:`newSubprojectFromEvent`. + */ +export function mutate(subproject: Subproject.Subproject, event: Event): Result.Type { + if (event.type !== "subproject_permission_granted") { + throw new VError(`illegal event type: ${event.type}`); + } + const eligibleIdentities = subproject.permissions[event.permission] || []; if (!eligibleIdentities.includes(event.grantee)) { eligibleIdentities.push(event.grantee); } subproject.permissions[event.permission] = eligibleIdentities; - - return Result.mapErr( - Subproject.validate(subproject), - error => new EventSourcingError({ ctx, event, target: subproject }, error), - ); } diff --git a/api/src/service/domain/workflow/subproject_permission_revoke.ts b/api/src/service/domain/workflow/subproject_permission_revoke.ts index 3c598272a..689d51191 100644 --- a/api/src/service/domain/workflow/subproject_permission_revoke.ts +++ b/api/src/service/domain/workflow/subproject_permission_revoke.ts @@ -1,6 +1,5 @@ import isEqual = require("lodash.isequal"); -import { produce } from "immer"; import Intent from "../../../authz/intents"; import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; @@ -12,6 +11,7 @@ import { Identity } from "../organization/identity"; import { ServiceUser } from "../organization/service_user"; import * as Project from "./project"; import * as Subproject from "./subproject"; +import * as SubprojectEventSourcing from "./subproject_eventsourcing"; import * as SubprojectPermissionRevoked from "./subproject_permission_revoked"; interface Repository { @@ -60,8 +60,10 @@ export async function revokeSubprojectPermission( } // Check that the new event is indeed valid: - const updatedSubproject = produce(subproject, draft => - SubprojectPermissionRevoked.apply(ctx, permissionRevoked, draft), + const updatedSubproject = SubprojectEventSourcing.newSubprojectFromEvent( + ctx, + subproject, + permissionRevoked, ); if (Result.isErr(updatedSubproject)) { return new InvalidCommand(ctx, permissionRevoked, [updatedSubproject]); diff --git a/api/src/service/domain/workflow/subproject_permission_revoked.ts b/api/src/service/domain/workflow/subproject_permission_revoked.ts index 39073534a..4b242411d 100644 --- a/api/src/service/domain/workflow/subproject_permission_revoked.ts +++ b/api/src/service/domain/workflow/subproject_permission_revoked.ts @@ -2,9 +2,7 @@ import Joi = require("joi"); import { VError } from "verror"; import Intent, { subprojectIntents } from "../../../authz/intents"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; import * as Subproject from "./subproject"; @@ -70,15 +68,25 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - subproject: Subproject.Subproject, -): Result.Type { +/** + * Applies the event to the given subproject, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * subproject is automatically validated when obtained using + * `subproject_eventsourcing.ts`:`newSubprojectFromEvent`. + */ +export function mutate(subproject: Subproject.Subproject, event: Event): Result.Type { + if (event.type !== "subproject_permission_revoked") { + throw new VError(`illegal event type: ${event.type}`); + } + const eligibleIdentities = subproject.permissions[event.permission]; if (eligibleIdentities === undefined) { // Nothing to do here.. - return subproject; + return; } const foundIndex = eligibleIdentities.indexOf(event.revokee); @@ -89,9 +97,4 @@ export function apply( } subproject.permissions[event.permission] = eligibleIdentities; - - return Result.mapErr( - Subproject.validate(subproject), - error => new EventSourcingError({ ctx, event, target: subproject }, error), - ); } diff --git a/api/src/service/domain/workflow/subproject_projected_budget_delete.ts b/api/src/service/domain/workflow/subproject_projected_budget_delete.ts index 2527ceba4..c5969ddf9 100644 --- a/api/src/service/domain/workflow/subproject_projected_budget_delete.ts +++ b/api/src/service/domain/workflow/subproject_projected_budget_delete.ts @@ -1,4 +1,5 @@ -import { produce } from "immer"; +import { isEqual } from "lodash"; + import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; @@ -9,6 +10,7 @@ import { ServiceUser } from "../organization/service_user"; import * as Project from "./project"; import { ProjectedBudget } from "./projected_budget"; import * as Subproject from "./subproject"; +import * as SubprojectEventSourcing from "./subproject_eventsourcing"; import * as SubprojectProjectedBudgetDeleted from "./subproject_projected_budget_deleted"; interface Repository { @@ -51,15 +53,18 @@ export async function deleteProjectedBudget( } // Check that the new event is indeed valid: - const result = produce(subproject, draft => - SubprojectProjectedBudgetDeleted.apply(ctx, budgetDeleted, draft), - ); + const result = SubprojectEventSourcing.newSubprojectFromEvent(ctx, subproject, budgetDeleted); if (Result.isErr(result)) { return new InvalidCommand(ctx, budgetDeleted, [result]); } - return { - newEvents: [budgetDeleted], - projectedBudgets: result.projectedBudgets, - }; + // Only emit the event if it causes any changes: + if (isEqual(subproject.projectedBudgets, result.projectedBudgets)) { + return { newEvents: [], projectedBudgets: result.projectedBudgets }; + } else { + return { + newEvents: [budgetDeleted], + projectedBudgets: result.projectedBudgets, + }; + } } diff --git a/api/src/service/domain/workflow/subproject_projected_budget_deleted.ts b/api/src/service/domain/workflow/subproject_projected_budget_deleted.ts index eca794d8f..284a7eee8 100644 --- a/api/src/service/domain/workflow/subproject_projected_budget_deleted.ts +++ b/api/src/service/domain/workflow/subproject_projected_budget_deleted.ts @@ -1,9 +1,7 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import { CurrencyCode, currencyCodeSchema } from "./money"; import * as Project from "./project"; @@ -69,22 +67,26 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - subproject: Subproject.Subproject, -): Result.Type { +/** + * Applies the event to the given subproject, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * subproject is automatically validated when obtained using + * `subproject_eventsourcing.ts`:`newSubprojectFromEvent`. + */ +export function mutate(subproject: Subproject.Subproject, event: Event): Result.Type { + if (event.type !== "subproject_projected_budget_deleted") { + throw new VError(`illegal event type: ${event.type}`); + } + // An organization may have multiple budgets, but any two budgets of the same // organization always have a different currency. The reasoning: if an organization // makes two financial commitments in the same currency, they can represented by one // commitment with the same currency and the sum of both commitments as its value. - const projectedBudgets = subproject.projectedBudgets.filter( - x => x.organization === event.organization && x.currencyCode === event.currencyCode, - ); - subproject = { ...subproject, projectedBudgets }; - - return Result.mapErr( - Subproject.validate(subproject), - error => new EventSourcingError({ ctx, event, target: subproject }, error), + subproject.projectedBudgets = subproject.projectedBudgets.filter( + x => !(x.organization === event.organization && x.currencyCode === event.currencyCode), ); } diff --git a/api/src/service/domain/workflow/subproject_projected_budget_update.ts b/api/src/service/domain/workflow/subproject_projected_budget_update.ts index 1ec9e127e..5536f1f6d 100644 --- a/api/src/service/domain/workflow/subproject_projected_budget_update.ts +++ b/api/src/service/domain/workflow/subproject_projected_budget_update.ts @@ -9,6 +9,8 @@ import * as Project from "./project"; import { ProjectedBudget } from "./projected_budget"; import * as Subproject from "./subproject"; import * as SubprojectProjectedBudgetUpdated from "./subproject_projected_budget_updated"; +import * as SubprojectEventSourcing from "./subproject_eventsourcing"; +import { isEqual } from "lodash"; interface Repository { getSubproject( @@ -51,13 +53,18 @@ export async function updateProjectedBudget( } // Check that the new event is indeed valid: - const result = SubprojectProjectedBudgetUpdated.apply(ctx, budgetUpdated, subproject); + const result = SubprojectEventSourcing.newSubprojectFromEvent(ctx, subproject, budgetUpdated); if (Result.isErr(result)) { return new InvalidCommand(ctx, budgetUpdated, [result]); } - return { - newEvents: [budgetUpdated], - projectedBudgets: result.projectedBudgets, - }; + // Only emit the event if it causes any changes: + if (isEqual(subproject.projectedBudgets, result.projectedBudgets)) { + return { newEvents: [], projectedBudgets: result.projectedBudgets }; + } else { + return { + newEvents: [budgetUpdated], + projectedBudgets: result.projectedBudgets, + }; + } } diff --git a/api/src/service/domain/workflow/subproject_projected_budget_updated.ts b/api/src/service/domain/workflow/subproject_projected_budget_updated.ts index 6dcdbbfe8..0eb46c9dd 100644 --- a/api/src/service/domain/workflow/subproject_projected_budget_updated.ts +++ b/api/src/service/domain/workflow/subproject_projected_budget_updated.ts @@ -1,9 +1,7 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import { CurrencyCode, currencyCodeSchema, MoneyAmount, moneyAmountSchema } from "./money"; import * as Project from "./project"; @@ -73,11 +71,21 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - subproject: Subproject.Subproject, -): Result.Type { +/** + * Applies the event to the given subproject, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * subproject is automatically validated when obtained using + * `subproject_eventsourcing.ts`:`newSubprojectFromEvent`. + */ +export function mutate(subproject: Subproject.Subproject, event: Event): Result.Type { + if (event.type !== "subproject_projected_budget_updated") { + throw new VError(`illegal event type: ${event.type}`); + } + // An organization may have multiple budgets, but any two budgets of the same // organization always have a different currency. The reasoning: if an organization // makes two financial commitments in the same currency, they can represented by one @@ -98,11 +106,4 @@ export function apply( value: event.value, }); } - - subproject = { ...subproject, projectedBudgets }; - - return Result.mapErr( - Subproject.validate(subproject), - error => new EventSourcingError({ ctx, event, target: subproject }, error), - ); } diff --git a/api/src/service/domain/workflow/subproject_update.ts b/api/src/service/domain/workflow/subproject_update.ts index cbe632840..9336658d6 100644 --- a/api/src/service/domain/workflow/subproject_update.ts +++ b/api/src/service/domain/workflow/subproject_update.ts @@ -1,7 +1,7 @@ import Joi = require("joi"); import isEqual = require("lodash.isequal"); -import { produce } from "immer"; import { VError } from "verror"; + import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; @@ -14,6 +14,7 @@ import * as UserRecord from "../organization/user_record"; import * as NotificationCreated from "./notification_created"; import * as Project from "./project"; import * as Subproject from "./subproject"; +import * as SubprojectEventSourcing from "./subproject_eventsourcing"; import * as SubprojectUpdated from "./subproject_updated"; export type RequestData = SubprojectUpdated.UpdatedData; @@ -40,7 +41,7 @@ export async function updateSubproject( data: RequestData, repository: Repository, ): Promise> { - let subproject = await repository.getSubproject(subprojectId, subprojectId); + const subproject = await repository.getSubproject(subprojectId, subprojectId); if (Result.isErr(subproject)) { return new NotFound(ctx, "subproject", subprojectId); @@ -67,14 +68,13 @@ export async function updateSubproject( } // Check that the new event is indeed valid: - const result = produce(subproject, draft => - SubprojectUpdated.apply(ctx, subprojectUpdated, draft), - ); + const result = SubprojectEventSourcing.newSubprojectFromEvent(ctx, subproject, subprojectUpdated); if (Result.isErr(result)) { return new InvalidCommand(ctx, subprojectUpdated, [result]); } - if (isEqual(subproject, result)) { + // Only emit the event if it causes any changes: + if (isEqualIgnoringLog(subproject, result)) { return { newEvents: [] }; } @@ -98,3 +98,12 @@ export async function updateSubproject( return { newEvents: [subprojectUpdated, ...notifications] }; } + +function isEqualIgnoringLog( + subprojectA: Subproject.Subproject, + subprojectB: Subproject.Subproject, +): boolean { + const { log: logA, ...a } = subprojectA; + const { log: logB, ...b } = subprojectB; + return isEqual(a, b); +} diff --git a/api/src/service/domain/workflow/subproject_updated.ts b/api/src/service/domain/workflow/subproject_updated.ts index 789a6f945..f123ef104 100644 --- a/api/src/service/domain/workflow/subproject_updated.ts +++ b/api/src/service/domain/workflow/subproject_updated.ts @@ -1,15 +1,12 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import * as AdditionalData from "../additional_data"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; import { projectedBudgetListSchema } from "./projected_budget"; import * as Subproject from "./subproject"; -import deepcopy from "../../../lib/deepcopy"; type eventTypeType = "subproject_updated"; const eventType: eventTypeType = "subproject_updated"; @@ -84,38 +81,36 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - subproject: Subproject.Subproject, -): Result.Type { +/** + * Applies the event to the given subproject, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * subproject is automatically validated when obtained using + * `subproject_eventsourcing.ts`:`newSubprojectFromEvent`. + */ +export function mutate(subproject: Subproject.Subproject, event: Event): Result.Type { + if (event.type !== "subproject_updated") { + throw new VError(`illegal event type: ${event.type}`); + } + if (subproject.status !== "open") { - return new EventSourcingError( - { ctx, event, target: subproject }, - `a subproject may only be updated if its status is "open"`, - ); + return new VError('a subproject may only be updated if its status is "open"'); } const update = event.update; - const additionalData = subproject.additionalData; + ["displayName", "description"].forEach(propname => { + if (update[propname] !== undefined) { + subproject[propname] = update[propname]; + } + }); + if (update.additionalData) { for (const key of Object.keys(update.additionalData)) { - additionalData[key] = update.additionalData[key]; + subproject.additionalData[key] = update.additionalData[key]; } } - - const nextState = { - ...subproject, - // Only updated if defined in the `update`: - ...(update.displayName !== undefined && { displayName: update.displayName }), - // Only updated if defined in the `update`: - ...(update.description !== undefined && { description: update.description }), - additionalData, - }; - - return Result.mapErr( - Subproject.validate(nextState), - error => new EventSourcingError({ ctx, event, target: subproject }, error), - ); } diff --git a/api/src/service/domain/workflow/workflowitem.ts b/api/src/service/domain/workflow/workflowitem.ts index f9086be5b..dc852de64 100644 --- a/api/src/service/domain/workflow/workflowitem.ts +++ b/api/src/service/domain/workflow/workflowitem.ts @@ -82,7 +82,6 @@ const schema = Joi.object().keys({ otherwise: Joi.optional(), }) .when("amountType", { is: Joi.valid("N/A"), then: Joi.forbidden() }), - // TODO: we should also check the amount type billingDate: Joi.date() .iso() .when("amountType", { diff --git a/api/src/service/domain/workflow/workflowitem_assign.ts b/api/src/service/domain/workflow/workflowitem_assign.ts index d5b6d888e..7a3aa2959 100644 --- a/api/src/service/domain/workflow/workflowitem_assign.ts +++ b/api/src/service/domain/workflow/workflowitem_assign.ts @@ -1,5 +1,5 @@ -import { produce } from "immer"; import { VError } from "verror"; + import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; @@ -14,6 +14,7 @@ import * as Project from "./project"; import * as Subproject from "./subproject"; import * as Workflowitem from "./workflowitem"; import * as WorkflowitemAssigned from "./workflowitem_assigned"; +import * as WorkflowitemEventSourcing from "./workflowitem_eventsourcing"; interface Repository { getWorkflowitem(workflowitemId: string): Promise>; @@ -63,9 +64,7 @@ export async function assignWorkflowitem( } // Check that the new event is indeed valid: - const result = produce(workflowitem, draft => - WorkflowitemAssigned.apply(ctx, assignEvent, draft), - ); + const result = WorkflowitemEventSourcing.newWorkflowitemFromEvent(ctx, workflowitem, assignEvent); if (Result.isErr(result)) { return new InvalidCommand(ctx, assignEvent, [result]); } diff --git a/api/src/service/domain/workflow/workflowitem_assigned.ts b/api/src/service/domain/workflow/workflowitem_assigned.ts index 437343371..8adffa34c 100644 --- a/api/src/service/domain/workflow/workflowitem_assigned.ts +++ b/api/src/service/domain/workflow/workflowitem_assigned.ts @@ -1,9 +1,7 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; import * as Subproject from "./subproject"; @@ -70,15 +68,22 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - workflowitem: Workflowitem.Workflowitem, -): Result.Type { - const newState = { ...workflowitem, assignee: event.assignee }; +/** + * Applies the event to the given workflowitem, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * workflowitem is automatically validated when obtained using + * `workflowitem_eventsourcing.ts`:`newWorkflowitemFromEvent`. + */ +export function mutate(workflowitem: Workflowitem.Workflowitem, event: Event): Result.Type { + if (event.type !== "workflowitem_assigned") { + throw new VError(`illegal event type: ${event.type}`); + } - return Result.mapErr( - Workflowitem.validate(newState), - error => new EventSourcingError({ ctx, event, target: workflowitem }, error), - ); + // Since we cannot have any side effects here, the existance of a user is expected to + // be validated before the event is produced. + workflowitem.assignee = event.assignee; } diff --git a/api/src/service/domain/workflow/workflowitem_close.ts b/api/src/service/domain/workflow/workflowitem_close.ts index bc735a3c9..a38731b3e 100644 --- a/api/src/service/domain/workflow/workflowitem_close.ts +++ b/api/src/service/domain/workflow/workflowitem_close.ts @@ -1,4 +1,3 @@ -import { produce } from "immer"; import { VError } from "verror"; import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; @@ -16,6 +15,7 @@ import * as Subproject from "./subproject"; import * as Workflowitem from "./workflowitem"; import { Id } from "./workflowitem"; import * as WorkflowitemClosed from "./workflowitem_closed"; +import * as WorkflowitemEventSourcing from "./workflowitem_eventsourcing"; import { sortWorkflowitems } from "./workflowitem_ordering"; interface Repository { @@ -97,8 +97,10 @@ export async function closeWorkflowitem( } // Check that the new event is indeed valid: - const result = produce(workflowitemToClose, draft => - WorkflowitemClosed.apply(ctx, closeEvent, draft), + const result = WorkflowitemEventSourcing.newWorkflowitemFromEvent( + ctx, + workflowitemToClose, + closeEvent, ); if (Result.isErr(result)) { return new InvalidCommand(ctx, closeEvent, [result]); diff --git a/api/src/service/domain/workflow/workflowitem_closed.ts b/api/src/service/domain/workflow/workflowitem_closed.ts index bc292b2bb..6f0080b55 100644 --- a/api/src/service/domain/workflow/workflowitem_closed.ts +++ b/api/src/service/domain/workflow/workflowitem_closed.ts @@ -1,14 +1,11 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; import * as Subproject from "./subproject"; import * as Workflowitem from "./workflowitem"; -import logger from "../../../lib/logger"; type eventTypeType = "workflowitem_closed"; const eventType: eventTypeType = "workflowitem_closed"; @@ -66,23 +63,29 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - workflowitem: Workflowitem.Workflowitem, -): Result.Type { - const billingDate = - workflowitem.billingDate === undefined ? event.time : workflowitem.billingDate; +/** + * Applies the event to the given workflowitem, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * workflowitem is automatically validated when obtained using + * `workflowitem_eventsourcing.ts`:`newWorkflowitemFromEvent`. + */ +export function mutate(workflowitem: Workflowitem.Workflowitem, event: Event): Result.Type { + if (event.type !== "workflowitem_closed") { + throw new VError(`illegal event type: ${event.type}`); + } - // TODO: handle amount type N/A - const nextState: Workflowitem.Workflowitem = { - ...workflowitem, - status: "closed", - billingDate, - }; + // Set billing date to the event timestamp if it makes sense for the amount type and + // isn't set already: + if ( + workflowitem.billingDate === undefined && + workflowitem.amountType !== "N/A" + ) { + workflowitem.billingDate = event.time; + } - return Result.mapErr( - Workflowitem.validate(nextState), - error => new EventSourcingError({ ctx, event, target: workflowitem }, error), - ); + workflowitem.status = "closed"; } diff --git a/api/src/service/domain/workflow/workflowitem_create.ts b/api/src/service/domain/workflow/workflowitem_create.ts index 319888934..3e6f65bdd 100644 --- a/api/src/service/domain/workflow/workflowitem_create.ts +++ b/api/src/service/domain/workflow/workflowitem_create.ts @@ -134,10 +134,10 @@ export async function createWorkflowitem( } } - // Check that the event is valid by trying to "apply" it: + // Check that the event is valid: const result = WorkflowitemCreated.createFrom(ctx, workflowitemCreated); if (Result.isErr(result)) { - return new InvalidCommand(ctx, workflowitemCreated, [result]); + return { newEvents: [], errors: [new InvalidCommand(ctx, workflowitemCreated, [result])] }; } return { newEvents: [workflowitemCreated], errors: [] }; diff --git a/api/src/service/domain/workflow/workflowitem_eventsourcing.ts b/api/src/service/domain/workflow/workflowitem_eventsourcing.ts index 0d271d352..19ac22bec 100644 --- a/api/src/service/domain/workflow/workflowitem_eventsourcing.ts +++ b/api/src/service/domain/workflow/workflowitem_eventsourcing.ts @@ -1,20 +1,18 @@ -import { produce } from "immer"; +import { VError } from "verror"; import { Ctx } from "../../../lib/ctx"; import deepcopy from "../../../lib/deepcopy"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; import { EventSourcingError } from "../errors/event_sourcing_error"; -import * as Project from "./project"; -import * as Subproject from "./subproject"; import * as Workflowitem from "./workflowitem"; +import * as WorkflowitemAssigned from "./workflowitem_assigned"; import * as WorkflowitemClosed from "./workflowitem_closed"; import * as WorkflowitemCreated from "./workflowitem_created"; -import * as WorkflowitemAssigned from "./workflowitem_assigned"; import * as WorkflowitemPermissionGranted from "./workflowitem_permission_granted"; import * as WorkflowitemPermissionRevoked from "./workflowitem_permission_revoked"; -import * as WorkflowitemUpdated from "./workflowitem_updated"; import { WorkflowitemTraceEvent } from "./workflowitem_trace_event"; +import * as WorkflowitemUpdated from "./workflowitem_updated"; export function sourceWorkflowitems( ctx: Ctx, @@ -25,90 +23,173 @@ export function sourceWorkflowitems( origin === undefined ? new Map() : new Map(origin); - const errors: Error[] = []; + for (const event of events) { if (!event.type.startsWith("workflowitem_")) { continue; } - const result = applyWorkflowitemEvents(ctx, items, event); - if (Result.isErr(result)) { - errors.push(result); + + const workflowitem = sourceEvent(ctx, event, items); + if (Result.isErr(workflowitem)) { + errors.push(workflowitem); } else { - result.log.push(newTraceEvent(result, event)); - items.set(result.id, result); + workflowitem.log.push(newTraceEvent(workflowitem, event)); + items.set(workflowitem.id, workflowitem); } } return { workflowitems: [...items.values()], errors }; } -function applyWorkflowitemEvents( +function newTraceEvent( + workflowitem: Workflowitem.Workflowitem, + event: BusinessEvent, +): WorkflowitemTraceEvent { + return { + entityId: workflowitem.id, + entityType: "workflowitem", + businessEvent: event, + snapshot: { + displayName: workflowitem.displayName, + amount: workflowitem.amount, + currency: workflowitem.currency, + amountType: workflowitem.amountType, + }, + }; +} + +function sourceEvent( ctx: Ctx, - items: Map, event: BusinessEvent, + workflowitems: Map, +): Result.Type { + const workflowitemId = getWorkflowitemId(event); + let workflowitem: Result.Type; + if (Result.isOk(workflowitemId)) { + // The event refers to an existing workflowitem, so + // the workflowitem should have been initialized already. + + workflowitem = get(workflowitems, workflowitemId); + if (Result.isErr(workflowitem)) { + return new VError( + `workflowitem ID ${workflowitemId} found in event ${event.type} is invalid`, + ); + } + + workflowitem = newWorkflowitemFromEvent(ctx, workflowitem, event); + if (Result.isErr(workflowitem)) { + return workflowitem; // <- event-sourcing error + } + } else { + // The event does not refer to a workflowitem ID, so it must be a creation event: + if (event.type !== "workflowitem_created") { + return new VError( + `event ${event.type} is not of type "workflowitem_created" but also ` + + "does not include a workflowitem ID", + ); + } + + workflowitem = WorkflowitemCreated.createFrom(ctx, event); + if (Result.isErr(workflowitem)) { + return new VError(workflowitem, "could not create workflowitem from event"); + } + } + + return workflowitem; +} + +function get( + workflowitems: Map, + workflowitemId: Workflowitem.Id, ): Result.Type { + const workflowitem = workflowitems.get(workflowitemId); + if (workflowitem === undefined) { + return new VError(`workflowitem ${workflowitemId} not yet initialized`); + } + return workflowitem; +} + +function getWorkflowitemId(event: BusinessEvent): Result.Type { switch (event.type) { + case "workflowitem_updated": case "workflowitem_assigned": - return apply(ctx, event, items, event.workflowitemId, WorkflowitemAssigned); case "workflowitem_closed": - return apply(ctx, event, items, event.workflowitemId, WorkflowitemClosed); - case "workflowitem_created": - return WorkflowitemCreated.createFrom(ctx, event); case "workflowitem_permission_granted": - return apply(ctx, event, items, event.workflowitemId, WorkflowitemPermissionGranted); case "workflowitem_permission_revoked": - return apply(ctx, event, items, event.workflowitemId, WorkflowitemPermissionRevoked); - case "workflowitem_updated": - return apply(ctx, event, items, event.workflowitemId, WorkflowitemUpdated); + return event.workflowitemId; + default: - throw Error(`not implemented: ${event.type}`); + return new VError(`cannot find workflowitem ID in event of type ${event.type}`); } } -type ApplyFn = ( +/** Returns a new workflowitem with the given event applied, or an error. */ +export function newWorkflowitemFromEvent( ctx: Ctx, - event: BusinessEvent, workflowitem: Workflowitem.Workflowitem, -) => Result.Type; -function apply( - ctx: Ctx, event: BusinessEvent, - workflowitems: Map, - workflowitemId: string, - eventModule: { apply: ApplyFn }, -) { - const workflowitem = workflowitems.get(workflowitemId); - if (workflowitem === undefined) { - return new EventSourcingError({ ctx, event, target: { workflowitemId } }, "not found"); - } +): Result.Type { + const eventModule = getEventModule(event); + + // Ensure that we never modify workflowitem or event in-place by passing copies. When + // copying the workflowitem, its event log is omitted for performance reasons. + const eventCopy = deepcopy(event); + const workflowitemCopy = copyWorkflowitemExceptLog(workflowitem); try { - return produce(workflowitem, draft => { - const result = eventModule.apply(ctx, event, draft); - if (Result.isErr(result)) { - throw result; - } - return result; - }); - } catch (err) { - return err; + // Apply the event to the copied workflowitem: + const mutation = eventModule.mutate(workflowitemCopy, eventCopy); + if (Result.isErr(mutation)) { + throw mutation; + } + + // Validate the modified workflowitem: + const validation = Workflowitem.validate(workflowitemCopy); + if (Result.isErr(validation)) { + throw validation; + } + + // Restore the event log: + workflowitemCopy.log = workflowitem.log; + + // Return the modified (and validated) workflowitem: + return workflowitemCopy; + } catch (error) { + return new EventSourcingError({ ctx, event, target: workflowitem }, error); } } -function newTraceEvent( +type EventModule = { + mutate: (workflowitem: Workflowitem.Workflowitem, event: BusinessEvent) => Result.Type; +}; +function getEventModule(event: BusinessEvent): EventModule { + switch (event.type) { + case "workflowitem_updated": + return WorkflowitemUpdated; + + case "workflowitem_assigned": + return WorkflowitemAssigned; + + case "workflowitem_closed": + return WorkflowitemClosed; + + case "workflowitem_permission_granted": + return WorkflowitemPermissionGranted; + + case "workflowitem_permission_revoked": + return WorkflowitemPermissionRevoked; + + default: + throw new VError(`unknown workflowitem event ${event.type}`); + } +} + +function copyWorkflowitemExceptLog( workflowitem: Workflowitem.Workflowitem, - event: BusinessEvent, -): WorkflowitemTraceEvent { - return { - entityId: workflowitem.id, - entityType: "workflowitem", - businessEvent: event, - snapshot: { - displayName: workflowitem.displayName, - amount: workflowitem.amount, - currency: workflowitem.currency, - amountType: workflowitem.amountType, - }, - }; +): Workflowitem.Workflowitem { + const { log, ...tmp } = workflowitem; + const copy = deepcopy(tmp); + (copy as any).log = []; + return copy as Workflowitem.Workflowitem; } diff --git a/api/src/service/domain/workflow/workflowitem_permission_grant.ts b/api/src/service/domain/workflow/workflowitem_permission_grant.ts index 9beee1cc8..98daa97ce 100644 --- a/api/src/service/domain/workflow/workflowitem_permission_grant.ts +++ b/api/src/service/domain/workflow/workflowitem_permission_grant.ts @@ -1,6 +1,5 @@ import isEqual = require("lodash.isequal"); -import { produce } from "immer"; import Intent from "../../../authz/intents"; import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; @@ -13,6 +12,7 @@ import { ServiceUser } from "../organization/service_user"; import * as Project from "./project"; import * as Subproject from "./subproject"; import * as Workflowitem from "./workflowitem"; +import * as WorkflowitemEventSourcing from "./workflowitem_eventsourcing"; import * as WorkflowitemPermissionGranted from "./workflowitem_permission_granted"; interface Repository { @@ -62,8 +62,10 @@ export async function grantWorkflowitemPermission( } // Check that the new event is indeed valid: - const updatedWorkflowitem = produce(workflowitem, draft => - WorkflowitemPermissionGranted.apply(ctx, permissionGranted, draft), + const updatedWorkflowitem = WorkflowitemEventSourcing.newWorkflowitemFromEvent( + ctx, + workflowitem, + permissionGranted, ); if (Result.isErr(updatedWorkflowitem)) { return new InvalidCommand(ctx, permissionGranted, [updatedWorkflowitem]); diff --git a/api/src/service/domain/workflow/workflowitem_permission_granted.ts b/api/src/service/domain/workflow/workflowitem_permission_granted.ts index df5ae386e..da61f6315 100644 --- a/api/src/service/domain/workflow/workflowitem_permission_granted.ts +++ b/api/src/service/domain/workflow/workflowitem_permission_granted.ts @@ -2,9 +2,7 @@ import Joi = require("joi"); import { VError } from "verror"; import Intent, { workflowitemIntents } from "../../../authz/intents"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; import * as Subproject from "./subproject"; @@ -75,18 +73,25 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - workflowitem: Workflowitem.Workflowitem, -): Result.Type { +/** + * Applies the event to the given workflowitem, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * workflowitem is automatically validated when obtained using + * `workflowitem_eventsourcing.ts`:`newWorkflowitemFromEvent`. + */ +export function mutate(workflowitem: Workflowitem.Workflowitem, event: Event): Result.Type { + if (event.type !== "workflowitem_permission_granted") { + throw new VError(`illegal event type: ${event.type}`); + } + const eligibleIdentities = workflowitem.permissions[event.permission] || []; if (!eligibleIdentities.includes(event.grantee)) { eligibleIdentities.push(event.grantee); } - return Result.mapErr( - Workflowitem.validate(workflowitem), - error => new EventSourcingError({ ctx, event, target: workflowitem }, error), - ); + workflowitem.permissions[event.permission] = eligibleIdentities; } diff --git a/api/src/service/domain/workflow/workflowitem_permission_revoke.ts b/api/src/service/domain/workflow/workflowitem_permission_revoke.ts index 0806b426b..ea49a5080 100644 --- a/api/src/service/domain/workflow/workflowitem_permission_revoke.ts +++ b/api/src/service/domain/workflow/workflowitem_permission_revoke.ts @@ -1,6 +1,5 @@ import isEqual = require("lodash.isequal"); -import { produce } from "immer"; import Intent from "../../../authz/intents"; import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; @@ -13,6 +12,7 @@ import { ServiceUser } from "../organization/service_user"; import * as Project from "./project"; import * as Subproject from "./subproject"; import * as Workflowitem from "./workflowitem"; +import * as WorkflowitemEventSourcing from "./workflowitem_eventsourcing"; import * as WorkflowitemPermissionRevoked from "./workflowitem_permission_revoked"; interface Repository { @@ -64,8 +64,10 @@ export async function revokeWorkflowitemPermission( } // Check that the new event is indeed valid: - const updatedWorkflowitem = produce(workflowitem, draft => - WorkflowitemPermissionRevoked.apply(ctx, permissionRevoked, draft), + const updatedWorkflowitem = WorkflowitemEventSourcing.newWorkflowitemFromEvent( + ctx, + workflowitem, + permissionRevoked, ); if (Result.isErr(updatedWorkflowitem)) { return new InvalidCommand(ctx, permissionRevoked, [updatedWorkflowitem]); diff --git a/api/src/service/domain/workflow/workflowitem_permission_revoked.ts b/api/src/service/domain/workflow/workflowitem_permission_revoked.ts index 3acb69c73..ca50b98e0 100644 --- a/api/src/service/domain/workflow/workflowitem_permission_revoked.ts +++ b/api/src/service/domain/workflow/workflowitem_permission_revoked.ts @@ -2,9 +2,7 @@ import Joi = require("joi"); import { VError } from "verror"; import Intent, { workflowitemIntents } from "../../../authz/intents"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; import * as Subproject from "./subproject"; @@ -75,15 +73,25 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - workflowitem: Workflowitem.Workflowitem, -): Result.Type { +/** + * Applies the event to the given workflowitem, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * workflowitem is automatically validated when obtained using + * `workflowitem_eventsourcing.ts`:`newWorkflowitemFromEvent`. + */ +export function mutate(workflowitem: Workflowitem.Workflowitem, event: Event): Result.Type { + if (event.type !== "workflowitem_permission_revoked") { + throw new VError(`illegal event type: ${event.type}`); + } + const eligibleIdentities = workflowitem.permissions[event.permission]; if (eligibleIdentities === undefined) { // Nothing to do here.. - return workflowitem; + return; } const foundIndex = eligibleIdentities.indexOf(event.revokee); @@ -94,9 +102,4 @@ export function apply( } workflowitem.permissions[event.permission] = eligibleIdentities; - - return Result.mapErr( - Workflowitem.validate(workflowitem), - error => new EventSourcingError({ ctx, event, target: workflowitem }, error), - ); } diff --git a/api/src/service/domain/workflow/workflowitem_update.ts b/api/src/service/domain/workflow/workflowitem_update.ts index aba9934df..c530d91f4 100644 --- a/api/src/service/domain/workflow/workflowitem_update.ts +++ b/api/src/service/domain/workflow/workflowitem_update.ts @@ -1,4 +1,3 @@ -import { produce } from "immer"; import isEqual = require("lodash.isequal"); import { VError } from "verror"; @@ -15,6 +14,7 @@ import * as NotificationCreated from "./notification_created"; import * as Project from "./project"; import * as Subproject from "./subproject"; import * as Workflowitem from "./workflowitem"; +import * as WorkflowitemEventSourcing from "./workflowitem_eventsourcing"; import * as WorkflowitemUpdated from "./workflowitem_updated"; export type RequestData = WorkflowitemUpdated.Modification; @@ -60,13 +60,13 @@ export async function updateWorkflowitem( } // Check that the new event is indeed valid: - const result = produce(workflowitem, draft => WorkflowitemUpdated.apply(ctx, newEvent, draft)); + const result = WorkflowitemEventSourcing.newWorkflowitemFromEvent(ctx, workflowitem, newEvent); if (Result.isErr(result)) { return new InvalidCommand(ctx, newEvent, [result]); } - // Ignore the update if it doesn't change anything: - if (isEqual(workflowitem, result)) { + // Only emit the event if it causes any changes: + if (isEqualIgnoringLog(workflowitem, result)) { return { newEvents: [], workflowitem }; } @@ -91,3 +91,12 @@ export async function updateWorkflowitem( return { newEvents: [newEvent, ...notifications], workflowitem: result }; } + +function isEqualIgnoringLog( + workflowitemA: Workflowitem.Workflowitem, + workflowitemB: Workflowitem.Workflowitem, +): boolean { + const { log: logA, ...a } = workflowitemA; + const { log: logB, ...b } = workflowitemB; + return isEqual(a, b); +} diff --git a/api/src/service/domain/workflow/workflowitem_updated.ts b/api/src/service/domain/workflow/workflowitem_updated.ts index aef53a81b..3de70e2bc 100644 --- a/api/src/service/domain/workflow/workflowitem_updated.ts +++ b/api/src/service/domain/workflow/workflowitem_updated.ts @@ -1,11 +1,8 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; -import deepcopy from "../../../lib/deepcopy"; import * as Result from "../../../result"; import * as AdditionalData from "../additional_data"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import { StoredDocument, storedDocumentSchema } from "./document"; import * as Project from "./project"; @@ -99,67 +96,61 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - workflowitem: Workflowitem.Workflowitem, -): Result.Type { +/** + * Applies the event to the given workflowitem, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * workflowitem is automatically validated when obtained using + * `workflowitem_eventsourcing.ts`:`newWorkflowitemFromEvent`. + */ +export function mutate(workflowitem: Workflowitem.Workflowitem, event: Event): Result.Type { + if (event.type !== "workflowitem_updated") { + throw new VError(`illegal event type: ${event.type}`); + } + if (workflowitem.status !== "open") { - return new EventSourcingError( - { ctx, event, target: workflowitem }, - `a workflowitem may only be updated if its status is "open"`, - ); + return new VError(`a workflowitem may only be updated if its status is "open"`); } - // deep copy and remove undefined fields of object const update = event.update; - const nextState = { - ...workflowitem, - // Only updated if defined in the `update`: - ...(update.displayName !== undefined && { displayName: update.displayName }), - ...(update.description !== undefined && { description: update.description }), - ...(update.amount !== undefined && { amount: update.amount }), - ...(update.currency !== undefined && { currency: update.currency }), - ...(update.amountType !== undefined && { amountType: update.amountType }), - ...(update.billingDate !== undefined && { billingDate: update.billingDate }), - ...(update.dueDate !== undefined && { dueDate: update.dueDate }), - additionalData: updateAdditionalData( - deepcopy(workflowitem.additionalData), - update.additionalData, - ), - documents: updateDocuments(deepcopy(workflowitem.documents), update.documents), - }; + [ + "displayName", + "description", + "amountType", + "amount", + "currency", + "exchangeRate", + "billingDate", + "dueDate", + ].forEach(propname => { + if (update[propname] !== undefined) { + workflowitem[propname] = update[propname]; + } + }); - // Setting the amount type to "N/A" removes fields that - // only make sense if amount type is _not_ "N/A": - if (update.amountType === "N/A") { - delete nextState.amount; - delete nextState.currency; - delete nextState.exchangeRate; - delete nextState.billingDate; + if (update.additionalData) { + for (const key of Object.keys(update.additionalData)) { + workflowitem.additionalData[key] = update.additionalData[key]; + } } - return Result.mapErr( - Workflowitem.validate(nextState), - error => new EventSourcingError({ ctx, event, target: workflowitem }, error), - ); -} - -function updateAdditionalData(additionalData: object, update?: object): object { - if (update) { - for (const key of Object.keys(update)) { - additionalData[key] = update[key]; - } + if (update.documents) { + // Any document with an ID that's already in use is silently ignored. + const currentIds = workflowitem.documents.map(x => x.id); + const newDocuments = update.documents.filter(x => !currentIds.includes(x.id)); + workflowitem.documents.push(...newDocuments); } - return additionalData; -} -function updateDocuments(documents: StoredDocument[], update?: StoredDocument[]): StoredDocument[] { - if (update) { - // Any document with an ID that's already in use is silently ignored! - const currentIds = documents.map(x => x.id); - return update.filter(x => !currentIds.includes(x.id)).concat(documents); + // Setting the amount type to "N/A" removes fields that + // only make sense if amount type is _not_ "N/A": + if (update.amountType === "N/A") { + delete workflowitem.amount; + delete workflowitem.currency; + delete workflowitem.exchangeRate; + delete workflowitem.billingDate; } - return documents; } diff --git a/api/src/service/domain/workflow/workflowitems_reorder.ts b/api/src/service/domain/workflow/workflowitems_reorder.ts index a59ee9e1f..02f0c5419 100644 --- a/api/src/service/domain/workflow/workflowitems_reorder.ts +++ b/api/src/service/domain/workflow/workflowitems_reorder.ts @@ -1,4 +1,3 @@ -import { produce } from "immer"; import isEqual = require("lodash.isequal"); import { Ctx } from "../../../lib/ctx"; @@ -10,6 +9,7 @@ import { NotFound } from "../errors/not_found"; import { ServiceUser } from "../organization/service_user"; import * as Project from "./project"; import * as Subproject from "./subproject"; +import * as SubprojectEventSourcing from "./subproject_eventsourcing"; import * as WorkflowitemOrdering from "./workflowitem_ordering"; import * as WorkflowitemsReordered from "./workflowitems_reordered"; @@ -39,8 +39,6 @@ export async function setWorkflowitemOrdering( return { newEvents: [] }; } - // TODO(kevin): Check that each ID refers to an existing workflowitem - const reorderEvent = WorkflowitemsReordered.createEvent( ctx.source, issuer.id, @@ -58,13 +56,15 @@ export async function setWorkflowitemOrdering( } // Check that the new event is indeed valid: - const result = produce(subproject, draft => - WorkflowitemsReordered.apply(ctx, reorderEvent, draft), - ); - + const result = SubprojectEventSourcing.newSubprojectFromEvent(ctx, subproject, reorderEvent); if (Result.isErr(result)) { return new InvalidCommand(ctx, reorderEvent, [result]); } - return { newEvents: [reorderEvent] }; + // Only emit the event if it causes any changes: + if (isEqual(subproject.workflowitemOrdering, result.workflowitemOrdering)) { + return { newEvents: [] }; + } else { + return { newEvents: [reorderEvent] }; + } } diff --git a/api/src/service/domain/workflow/workflowitems_reordered.ts b/api/src/service/domain/workflow/workflowitems_reordered.ts index 68470c258..6175feb2d 100644 --- a/api/src/service/domain/workflow/workflowitems_reordered.ts +++ b/api/src/service/domain/workflow/workflowitems_reordered.ts @@ -1,9 +1,7 @@ import Joi = require("joi"); import { VError } from "verror"; -import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; -import { EventSourcingError } from "../errors/event_sourcing_error"; import { Identity } from "../organization/identity"; import * as Project from "./project"; import * as Subproject from "./subproject"; @@ -68,15 +66,20 @@ export function validate(input: any): Result.Type { return !error ? value : error; } -export function apply( - ctx: Ctx, - event: Event, - subproject: Subproject.Subproject, -): Result.Type { - const nextState = { ...subproject, workflowitemOrdering: event.ordering }; +/** + * Applies the event to the given subproject, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified + * subproject is automatically validated when obtained using + * `subproject_eventsourcing.ts`:`newSubprojectFromEvent`. + */ +export function mutate(subproject: Subproject.Subproject, event: Event): Result.Type { + if (event.type !== "workflowitems_reordered") { + throw new VError(`illegal event type: ${event.type}`); + } - return Result.mapErr( - Subproject.validate(nextState), - error => new EventSourcingError({ ctx, event, target: subproject }, error), - ); + subproject.workflowitemOrdering = event.ordering; } diff --git a/api/src/service/project_projected_budget_delete.ts b/api/src/service/project_projected_budget_delete.ts index 58c972616..cbf738420 100644 --- a/api/src/service/project_projected_budget_delete.ts +++ b/api/src/service/project_projected_budget_delete.ts @@ -5,8 +5,8 @@ import { ConnToken } from "./conn"; import { ServiceUser } from "./domain/organization/service_user"; import { CurrencyCode } from "./domain/workflow/money"; import * as Project from "./domain/workflow/project"; -import { ProjectedBudget } from "./domain/workflow/projected_budget"; import * as ProjectProjectedBudgetDelete from "./domain/workflow/project_projected_budget_delete"; +import { ProjectedBudget } from "./domain/workflow/projected_budget"; import { store } from "./store"; export async function deleteProjectedBudget( @@ -25,8 +25,8 @@ export async function deleteProjectedBudget( organization, currencyCode, { - getProject: async projectId => { - return cache.getProject(projectId); + getProject: async pId => { + return cache.getProject(pId); }, }, ), @@ -37,5 +37,5 @@ export async function deleteProjectedBudget( await store(conn, ctx, event); } - return result.newState; + return result.projectedBudgets; } diff --git a/api/src/service/project_projected_budget_update.ts b/api/src/service/project_projected_budget_update.ts index 5cff10175..58438a8cc 100644 --- a/api/src/service/project_projected_budget_update.ts +++ b/api/src/service/project_projected_budget_update.ts @@ -5,8 +5,8 @@ import { ConnToken } from "./conn"; import { ServiceUser } from "./domain/organization/service_user"; import { CurrencyCode, MoneyAmount } from "./domain/workflow/money"; import * as Project from "./domain/workflow/project"; -import { ProjectedBudget } from "./domain/workflow/projected_budget"; import * as ProjectProjectedBudgetUpdate from "./domain/workflow/project_projected_budget_update"; +import { ProjectedBudget } from "./domain/workflow/projected_budget"; import { store } from "./store"; export async function updateProjectedBudget( @@ -27,8 +27,8 @@ export async function updateProjectedBudget( value, currencyCode, { - getProject: async projectId => { - return cache.getProject(projectId); + getProject: async pId => { + return cache.getProject(pId); }, }, ), @@ -39,5 +39,5 @@ export async function updateProjectedBudget( await store(conn, ctx, event); } - return result.newState; + return result.projectedBudgets; }