From ee6e5e22b1cff1d7aa2cce44d38428449b59dcaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= <57442769+gjedlicska@users.noreply.github.com> Date: Fri, 12 Jul 2024 15:35:40 +0200 Subject: [PATCH] gergo/eventBus (#2498) * feat(eventBus): WIP event bus typescript wizardy * feat(eventBus): final eventbus setup with all the typescript foo * fix(workspaces): fix workspace core imports * test(workspaces): fix expected events name * test(workspaces): fix tests --- .../modules/shared/services/eventBus.ts | 126 ++++++++++++ .../modules/shared/test/authz.e2e.spec.js | 1 - .../modules/shared/test/unit/eventBus.spec.ts | 188 ++++++++++++++++++ .../modules/workspaces/domain/operations.ts | 4 +- .../workspaces/repositories/workspaces.ts | 2 +- .../workspaces/services/workspaceCreation.ts | 7 +- .../services/workspaceRoleCreation.ts | 6 +- .../tests/integration/repositories.spec.ts | 2 +- .../unit/services/workspaceCreation.spec.ts | 13 +- .../services/workspaceRoleCreation.spec.ts | 18 +- .../utils/isUserLastWorkspaceAdmin.spec.ts | 2 +- .../utils/isUserLastWorkspaceAdmin.ts | 2 +- .../modules/workspacesCore/domain/events.ts | 16 +- .../domain/types.ts | 0 14 files changed, 355 insertions(+), 32 deletions(-) create mode 100644 packages/server/modules/shared/services/eventBus.ts delete mode 100644 packages/server/modules/shared/test/authz.e2e.spec.js create mode 100644 packages/server/modules/shared/test/unit/eventBus.spec.ts rename packages/server/modules/{workspaces => workspacesCore}/domain/types.ts (100%) diff --git a/packages/server/modules/shared/services/eventBus.ts b/packages/server/modules/shared/services/eventBus.ts new file mode 100644 index 0000000000..095cb940ef --- /dev/null +++ b/packages/server/modules/shared/services/eventBus.ts @@ -0,0 +1,126 @@ +import { + WorkspaceEventsPayloads, + workspaceEventNamespace +} from '@/modules/workspacesCore/domain/events' +import { MaybeAsync } from '@speckle/shared' +import { UnionToIntersection } from 'type-fest' + +import EventEmitter from 'eventemitter2' + +type EventWildcard = '*' + +type TestEvents = { + ['test.string']: string + ['test.number']: number +} + +// we should only ever extend this type, other helper types will be derived from this +type EventsByNamespace = { + test: TestEvents + [workspaceEventNamespace]: WorkspaceEventsPayloads +} + +type EventTypes = UnionToIntersection + +// generated union to collect all event +type EventNamesByNamespace = { + [Namespace in keyof EventsByNamespace]: keyof EventsByNamespace[Namespace] +} + +// generated type for a top level wildcard one level nested wildcards per namespace and each possible event +type EventSubscriptionKey = + | EventWildcard + | `${keyof EventNamesByNamespace}.${EventWildcard}` + | { + [Namespace in keyof EventNamesByNamespace]: EventNamesByNamespace[Namespace] + }[keyof EventNamesByNamespace] + +// generated flatten of each specific event name with the emitted event type +type EventPayloadsMap = UnionToIntersection< + EventPayloadsByNamespaceMap[keyof EventPayloadsByNamespaceMap] +> + +type EventNames = keyof EventPayloadsMap + +type EventPayloadsByNamespaceMap = { + // for each event namespace + [Key in keyof EventsByNamespace]: { + // for each event + [EventName in keyof EventsByNamespace[Key]]: { + // create a type with they original event as the payload, and the eventName + eventName: EventName + payload: EventsByNamespace[Key][EventName] + } + } +} + +type EventPayload = T extends EventWildcard + ? // if event key is "*", get all events from the flat object + EventPayloadsMap[keyof EventPayloadsMap] + : // else if, the key is a "namespace.*" wildcard + T extends `${infer Namespace}.${EventWildcard}` + ? // the Namespace needs to extend the keys of the type, otherwise we never + Namespace extends keyof EventPayloadsByNamespaceMap + ? // get the union type of all possible events in a namespace + EventPayloadsByNamespaceMap[Namespace][keyof EventPayloadsByNamespaceMap[Namespace]] + : never + : // else if, the key is a "namespace.event" concrete key + T extends keyof EventPayloadsMap + ? EventPayloadsMap[T] + : never + +export function initializeEventBus() { + const emitter = new EventEmitter({ wildcard: true }) + + return { + /** + * Emit a module event. This function must be awaited to ensure all listeners + * execute. Any errors thrown in the listeners will bubble up and throw from + * the part of code that triggers this emit() call. + */ + emit: async (args: { + eventName: EventName + payload: EventTypes[EventName] + }): Promise => { + // curate the proper payload here and eventName object here, before emitting + return emitter.emitAsync(args.eventName, args) + }, + + /** + * Listen for module events. Any errors thrown here will bubble out of where + * emit() was invoked. + * + * @returns Callback for stopping listening + */ + listen: ( + eventName: K, + // we should add some error type object here with a type discriminator + handler: (event: EventPayload) => MaybeAsync + ) => { + emitter.on(eventName, handler, { + async: true, + promisify: true + }) + + return () => { + emitter.removeListener(eventName, handler) + } + }, + + /** + * Destroy event emitter + */ + destroy() { + emitter.removeAllListeners() + } + } +} + +type EventBus = ReturnType + +let eventBus: EventBus + +export function getEventBus(): EventBus { + if (!eventBus) eventBus = initializeEventBus() + return eventBus +} diff --git a/packages/server/modules/shared/test/authz.e2e.spec.js b/packages/server/modules/shared/test/authz.e2e.spec.js deleted file mode 100644 index 39265a3d25..0000000000 --- a/packages/server/modules/shared/test/authz.e2e.spec.js +++ /dev/null @@ -1 +0,0 @@ -describe('AuthZ E2E @shared', () => {}) diff --git a/packages/server/modules/shared/test/unit/eventBus.spec.ts b/packages/server/modules/shared/test/unit/eventBus.spec.ts new file mode 100644 index 0000000000..d0644a3c02 --- /dev/null +++ b/packages/server/modules/shared/test/unit/eventBus.spec.ts @@ -0,0 +1,188 @@ +import { getEventBus, initializeEventBus } from '@/modules/shared/services/eventBus' +import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' +import { Workspace } from '@/modules/workspacesCore/domain/types' +import { Roles } from '@speckle/shared' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' + +const createFakeWorkspace = (): Workspace => { + return { + id: cryptoRandomString({ length: 10 }), + description: cryptoRandomString({ length: 10 }), + logoUrl: null, + name: cryptoRandomString({ length: 10 }), + updatedAt: new Date(), + createdAt: new Date() + } +} + +describe('Event Bus', () => { + describe('initializeEventBus creates an event bus instance, that', () => { + it('calls back all the listeners', async () => { + const testEventBus = initializeEventBus() + const eventNames: string[] = [] + testEventBus.listen('test.string', ({ eventName }) => { + eventNames.push(eventName) + }) + + testEventBus.listen('test.string', ({ eventName }) => { + eventNames.push(eventName) + }) + + await testEventBus.emit({ eventName: 'test.number', payload: 1 }) + expect(eventNames.length).to.equal(0) + + const eventName = 'test.string' as const + await testEventBus.emit({ eventName, payload: 'fake event' }) + + expect(eventNames.length).to.equal(2) + expect(eventNames).to.deep.equal([eventName, eventName]) + }) + it('can removes listeners from itself', async () => { + const testEventBus = initializeEventBus() + const eventNumbers: number[] = [] + testEventBus.listen('test.string', () => { + eventNumbers.push(1) + }) + + const listenerOff = testEventBus.listen('test.string', () => { + eventNumbers.push(2) + }) + + await testEventBus.emit({ eventName: 'test.string', payload: 'fake event' }) + expect(eventNumbers.sort((a, b) => a - b)).to.deep.equal([1, 2]) + + listenerOff() + + await testEventBus.emit({ eventName: 'test.string', payload: 'fake event' }) + expect(eventNumbers.sort((a, b) => a - b)).to.deep.equal([1, 1, 2]) + }) + it('returns results from listeners to the emitter', async () => { + const testEventBus = initializeEventBus() + + testEventBus.listen('test.string', ({ payload }) => ({ + outcome: payload + })) + + const lookWhatHappened = 'echo this back to me' + const results = await testEventBus.emit({ + eventName: 'test.string', + payload: lookWhatHappened + }) + + expect(results.length).to.equal(1) + expect(results[0]).to.deep.equal({ outcome: lookWhatHappened }) + }) + it('bubbles up listener exceptions to emitter', async () => { + const testEventBus = initializeEventBus() + + testEventBus.listen('test.string', ({ payload }) => { + throw new Error(payload) + }) + + const lookWhatHappened = 'kabumm' + try { + await testEventBus.emit({ eventName: 'test.string', payload: lookWhatHappened }) + throw new Error('this should have thrown by now') + } catch (error) { + if (error instanceof Error) { + expect(error.message).to.equal(lookWhatHappened) + } else { + throw error + } + } + }) + it('can be destroyed, removing all listeners', async () => { + const testEventBus = initializeEventBus() + const eventNumbers: number[] = [] + testEventBus.listen('test.string', () => { + eventNumbers.push(1) + }) + + testEventBus.listen('test.string', () => { + eventNumbers.push(2) + }) + + await testEventBus.emit({ eventName: 'test.string', payload: 'test' }) + expect(eventNumbers.sort((a, b) => a - b)).to.deep.equal([1, 2]) + + testEventBus.destroy() + + await testEventBus.emit({ eventName: 'test.string', payload: 'test' }) + expect(eventNumbers.sort((a, b) => a - b)).to.deep.equal([1, 2]) + }) + }) + describe('getEventBus', () => { + it('returns a unified event bus instance', async () => { + const bus1 = getEventBus() + const bus2 = getEventBus() + + const workspaces: Workspace[] = [] + + bus1.listen(WorkspaceEvents.Created, ({ payload }) => { + workspaces.push(payload) + }) + + bus2.listen(WorkspaceEvents.Created, ({ payload }) => { + workspaces.push(payload) + }) + + const workspacePayload = { + ...createFakeWorkspace(), + createdByUserId: cryptoRandomString({ length: 10 }), + eventName: WorkspaceEvents.Created + } + + await bus1.emit({ eventName: WorkspaceEvents.Created, payload: workspacePayload }) + + expect(workspaces.length).to.equal(2) + expect(workspaces).to.deep.equal([workspacePayload, workspacePayload]) + }) + it('allows to subscribe to wildcard events', async () => { + const eventBus = getEventBus() + + const events: string[] = [] + + eventBus.listen('workspace.*', ({ payload, eventName }) => { + switch (eventName) { + case 'workspace.created': + events.push(payload.id) + break + case 'workspace.role-deleted': + events.push(payload.userId) + break + default: + events.push('default') + } + }) + + const workspace = createFakeWorkspace() + + await eventBus.emit({ + eventName: WorkspaceEvents.Created, + payload: { + ...workspace, + createdByUserId: cryptoRandomString({ length: 10 }) + } + }) + + const workspaceAcl = { + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }), + role: Roles.Workspace.Member + } + + await eventBus.emit({ + eventName: WorkspaceEvents.RoleDeleted, + payload: workspaceAcl + }) + + await eventBus.emit({ + eventName: WorkspaceEvents.RoleUpdated, + payload: workspaceAcl + }) + + expect([workspace.id, workspaceAcl.userId, 'default']).to.deep.equal(events) + }) + }) +}) diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index 562c5712de..2ffed32291 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -2,7 +2,7 @@ import { WorkspaceEvents, WorkspaceEventsPayloads } from '@/modules/workspacesCore/domain/events' -import { Workspace, WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' /** Workspace */ @@ -70,6 +70,6 @@ export type StoreBlob = (args: string) => Promise /** Events */ export type EmitWorkspaceEvent = (args: { - event: TEvent + eventName: TEvent payload: WorkspaceEventsPayloads[TEvent] }) => Promise diff --git a/packages/server/modules/workspaces/repositories/workspaces.ts b/packages/server/modules/workspaces/repositories/workspaces.ts index 46677005d5..2ab15a06c4 100644 --- a/packages/server/modules/workspaces/repositories/workspaces.ts +++ b/packages/server/modules/workspaces/repositories/workspaces.ts @@ -1,4 +1,4 @@ -import { Workspace, WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' import { DeleteWorkspaceRole, GetWorkspace, diff --git a/packages/server/modules/workspaces/services/workspaceCreation.ts b/packages/server/modules/workspaces/services/workspaceCreation.ts index 43e026a5e3..cc2839d469 100644 --- a/packages/server/modules/workspaces/services/workspaceCreation.ts +++ b/packages/server/modules/workspaces/services/workspaceCreation.ts @@ -5,7 +5,7 @@ import { UpsertWorkspace, UpsertWorkspaceRole } from '@/modules/workspaces/domain/operations' -import { Workspace } from '@/modules/workspaces/domain/types' +import { Workspace } from '@/modules/workspacesCore/domain/types' import { Roles } from '@speckle/shared' import cryptoRandomString from 'crypto-random-string' @@ -47,7 +47,10 @@ export const createWorkspaceFactory = workspaceId: workspace.id }) - await emitWorkspaceEvent({ event: WorkspaceEvents.Created, payload: workspace }) + await emitWorkspaceEvent({ + eventName: WorkspaceEvents.Created, + payload: { ...workspace, createdByUserId: userId } + }) // emit a workspace created event return workspace diff --git a/packages/server/modules/workspaces/services/workspaceRoleCreation.ts b/packages/server/modules/workspaces/services/workspaceRoleCreation.ts index 00ef43db7b..64b2859e71 100644 --- a/packages/server/modules/workspaces/services/workspaceRoleCreation.ts +++ b/packages/server/modules/workspaces/services/workspaceRoleCreation.ts @@ -5,7 +5,7 @@ import { GetWorkspaceRoles, UpsertWorkspaceRole } from '@/modules/workspaces/domain/operations' -import { WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types' import { WorkspaceAdminRequiredError } from '@/modules/workspaces/errors/workspace' import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/isUserLastWorkspaceAdmin' import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' @@ -41,7 +41,7 @@ export const deleteWorkspaceRoleFactory = return null } - emitWorkspaceEvent({ event: WorkspaceEvents.RoleDeleted, payload: deletedRole }) + emitWorkspaceEvent({ eventName: WorkspaceEvents.RoleDeleted, payload: deletedRole }) return deletedRole } @@ -83,7 +83,7 @@ export const setWorkspaceRoleFactory = await upsertWorkspaceRole({ userId, workspaceId, role }) await emitWorkspaceEvent({ - event: WorkspaceEvents.RoleUpdated, + eventName: WorkspaceEvents.RoleUpdated, payload: { userId, workspaceId, role } }) } diff --git a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts index 6a65d74b07..0596f09c48 100644 --- a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts @@ -10,7 +10,7 @@ import { import db from '@/db/knex' import cryptoRandomString from 'crypto-random-string' import { expect } from 'chai' -import { Workspace, WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' import { expectToThrow } from '@/test/assertionHelper' import { BasicTestUser, createTestUser } from '@/test/authHelper' diff --git a/packages/server/modules/workspaces/tests/unit/services/workspaceCreation.spec.ts b/packages/server/modules/workspaces/tests/unit/services/workspaceCreation.spec.ts index e4ca192442..f202933f17 100644 --- a/packages/server/modules/workspaces/tests/unit/services/workspaceCreation.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/workspaceCreation.spec.ts @@ -1,8 +1,9 @@ -import { Workspace, WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' import { createWorkspaceFactory } from '@/modules/workspaces/services/workspaceCreation' import { Roles } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' +import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' describe('Workspace services', () => { describe('createWorkspaceFactory creates a function, that', () => { @@ -60,15 +61,15 @@ describe('Workspace services', () => { it('emits a workspace created event', async () => { const eventData = { isCalled: false, - event: '', + eventName: '', payload: {} } const createWorkspace = createWorkspaceFactory({ upsertWorkspace: async () => {}, upsertWorkspaceRole: async () => {}, - emitWorkspaceEvent: async ({ event, payload }) => { + emitWorkspaceEvent: async ({ eventName, payload }) => { eventData.isCalled = true - eventData.event = event + eventData.eventName = eventName eventData.payload = payload return [] }, @@ -88,8 +89,8 @@ describe('Workspace services', () => { }) expect(eventData.isCalled).to.equal(true) - expect(eventData.event).to.equal('created') - expect(eventData.payload).to.deep.equal(workspace) + expect(eventData.eventName).to.equal(WorkspaceEvents.Created) + expect(eventData.payload).to.deep.equal({ ...workspace, createdByUserId: userId }) }) }) }) diff --git a/packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts b/packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts index 9c20fea27d..e147cc3cbf 100644 --- a/packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts @@ -1,4 +1,4 @@ -import { WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types' import { deleteWorkspaceRoleFactory, setWorkspaceRoleFactory @@ -41,7 +41,7 @@ describe('Workspace role services', () => { it('emits a role-deleted event', async () => { const eventData = { isCalled: false, - event: '', + eventName: '', payload: {} } @@ -57,9 +57,9 @@ describe('Workspace role services', () => { deleteWorkspaceRole: async () => { return storedRoles[0] }, - emitWorkspaceEvent: async ({ event, payload }) => { + emitWorkspaceEvent: async ({ eventName, payload }) => { eventData.isCalled = true - eventData.event = event + eventData.eventName = eventName eventData.payload = payload return [] @@ -69,7 +69,7 @@ describe('Workspace role services', () => { await deleteWorkspaceRole({ userId, workspaceId }) expect(eventData.isCalled).to.be.true - expect(eventData.event).to.equal(WorkspaceEvents.RoleDeleted) + expect(eventData.eventName).to.equal(WorkspaceEvents.RoleDeleted) expect(eventData.payload).to.deep.equal(role) }) it('throws if attempting to delete the last admin from a workspace', async () => { @@ -123,7 +123,7 @@ describe('Workspace role services', () => { it('emits a role-updated event', async () => { const eventData = { isCalled: false, - event: '', + eventName: '', payload: {} } @@ -135,9 +135,9 @@ describe('Workspace role services', () => { const setWorkspaceRole = setWorkspaceRoleFactory({ getWorkspaceRoles: async () => [], upsertWorkspaceRole: async () => {}, - emitWorkspaceEvent: async ({ event, payload }) => { + emitWorkspaceEvent: async ({ eventName, payload }) => { eventData.isCalled = true - eventData.event = event + eventData.eventName = eventName eventData.payload = payload return [] @@ -147,7 +147,7 @@ describe('Workspace role services', () => { await setWorkspaceRole(role) expect(eventData.isCalled).to.be.true - expect(eventData.event).to.equal(WorkspaceEvents.RoleUpdated) + expect(eventData.eventName).to.equal(WorkspaceEvents.RoleUpdated) expect(eventData.payload).to.deep.equal(role) }) it('throws if attempting to remove the last admin in a workspace', async () => { diff --git a/packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts b/packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts index 7721e15b4c..539dc7b6ec 100644 --- a/packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts @@ -1,5 +1,5 @@ import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/isUserLastWorkspaceAdmin' -import { WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types' import { expect } from 'chai' import { Roles } from '@speckle/shared' diff --git a/packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts b/packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts index 03f87b2ecd..e4a0f86af9 100644 --- a/packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts +++ b/packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts @@ -1,4 +1,4 @@ -import { WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types' export const isUserLastWorkspaceAdmin = ( workspaceRoles: WorkspaceAcl[], diff --git a/packages/server/modules/workspacesCore/domain/events.ts b/packages/server/modules/workspacesCore/domain/events.ts index ca718d38a9..75833ec552 100644 --- a/packages/server/modules/workspacesCore/domain/events.ts +++ b/packages/server/modules/workspacesCore/domain/events.ts @@ -1,14 +1,20 @@ -import { Workspace, WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' + +export const workspaceEventNamespace = 'workspace' as const + +const workspaceEventPrefix = `${workspaceEventNamespace}.` as const export const WorkspaceEvents = { - Created: 'created', - RoleDeleted: 'role-deleted', - RoleUpdated: 'role-updated' + Created: `${workspaceEventPrefix}created`, + RoleDeleted: `${workspaceEventPrefix}role-deleted`, + RoleUpdated: `${workspaceEventPrefix}role-updated` } as const export type WorkspaceEvents = (typeof WorkspaceEvents)[keyof typeof WorkspaceEvents] -type WorkspaceCreatedPayload = Workspace +type WorkspaceCreatedPayload = Workspace & { + createdByUserId: string +} type WorkspaceRoleDeletedPayload = WorkspaceAcl type WorkspaceRoleUpdatedPayload = WorkspaceAcl diff --git a/packages/server/modules/workspaces/domain/types.ts b/packages/server/modules/workspacesCore/domain/types.ts similarity index 100% rename from packages/server/modules/workspaces/domain/types.ts rename to packages/server/modules/workspacesCore/domain/types.ts