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
This commit is contained in:
Gergő Jedlicska
2024-07-12 15:35:40 +02:00
committed by GitHub
parent 4da196ec48
commit ee6e5e22b1
14 changed files with 355 additions and 32 deletions
@@ -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<EventsByNamespace[keyof EventsByNamespace]>
// 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 EventSubscriptionKey> = 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 <EventName extends EventNames>(args: {
eventName: EventName
payload: EventTypes[EventName]
}): Promise<unknown[]> => {
// 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: <K extends EventSubscriptionKey>(
eventName: K,
// we should add some error type object here with a type discriminator
handler: (event: EventPayload<K>) => MaybeAsync<unknown>
) => {
emitter.on(eventName, handler, {
async: true,
promisify: true
})
return () => {
emitter.removeListener(eventName, handler)
}
},
/**
* Destroy event emitter
*/
destroy() {
emitter.removeAllListeners()
}
}
}
type EventBus = ReturnType<typeof initializeEventBus>
let eventBus: EventBus
export function getEventBus(): EventBus {
if (!eventBus) eventBus = initializeEventBus()
return eventBus
}
@@ -1 +0,0 @@
describe('AuthZ E2E @shared', () => {})
@@ -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)
})
})
})
@@ -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<string>
/** Events */
export type EmitWorkspaceEvent = <TEvent extends WorkspaceEvents>(args: {
event: TEvent
eventName: TEvent
payload: WorkspaceEventsPayloads[TEvent]
}) => Promise<unknown[]>
@@ -1,4 +1,4 @@
import { Workspace, WorkspaceAcl } from '@/modules/workspaces/domain/types'
import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
import {
DeleteWorkspaceRole,
GetWorkspace,
@@ -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
@@ -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 }
})
}
@@ -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'
@@ -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 })
})
})
})
@@ -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 () => {
@@ -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'
@@ -1,4 +1,4 @@
import { WorkspaceAcl } from '@/modules/workspaces/domain/types'
import { WorkspaceAcl } from '@/modules/workspacesCore/domain/types'
export const isUserLastWorkspaceAdmin = (
workspaceRoles: WorkspaceAcl[],
@@ -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