feat(server): cluster level event system (#9884)

This commit is contained in:
forehalo
2025-01-25 14:51:03 +00:00
parent 0d2c2ea21e
commit 6370f45928
43 changed files with 634 additions and 364 deletions
@@ -0,0 +1,62 @@
import { INestApplication } from '@nestjs/common';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { EventBus } from '../../base';
import { SocketIoAdapter } from '../../base/websocket';
import { createTestingModule } from '../utils';
import { Listeners } from './provider';
const test = ava as TestFn<{
app1: INestApplication;
app2: INestApplication;
}>;
async function createApp() {
const m = await createTestingModule(
{
providers: [Listeners],
},
false
);
const app = m.createNestApplication({ logger: false });
app.useWebSocketAdapter(new SocketIoAdapter(app));
await app.init();
return app;
}
test.before(async t => {
t.context.app1 = await createApp();
t.context.app2 = await createApp();
});
test.after(async t => {
await t.context.app1.close();
await t.context.app2.close();
});
test('should broadcast event to cluster instances', async t => {
const { app1, app2 } = t.context;
// app 1 for listening
const eventbus1 = app1.get(EventBus);
const listener = Sinon.spy(app1.get(Listeners), 'onTestEvent');
const runtimeListener = Sinon.stub().returns({ count: 2 });
const off = eventbus1.on('__test__.event', runtimeListener);
// app 2 for broadcasting
const eventbus2 = app2.get(EventBus);
eventbus2.broadcast('__test__.event', { count: 0 });
// cause the cross instances broadcasting is asynchronization calling
// we should wait for the event's arriving before asserting
await eventbus1.waitFor('__test__.event');
t.true(listener.calledOnceWith({ count: 0 }));
t.true(runtimeListener.calledOnceWith({ count: 0 }));
off();
});
@@ -0,0 +1,111 @@
import { TestingModule } from '@nestjs/testing';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { EventBus, metrics } from '../../base';
import { createTestingModule } from '../utils';
import { Listeners } from './provider';
export const test = ava as TestFn<{
module: TestingModule;
eventbus: EventBus;
listener: Sinon.SinonSpy;
}>;
test.before(async t => {
const m = await createTestingModule({
providers: [Listeners],
});
const eventbus = m.get(EventBus);
t.context.module = m;
t.context.eventbus = eventbus;
t.context.listener = Sinon.spy(m.get(Listeners), 'onTestEvent');
});
test.afterEach(() => {
Sinon.reset();
});
test.after(async t => {
await t.context.module.close();
});
test('should register event listener', t => {
const { eventbus } = t.context;
// @ts-expect-error private member
t.true(eventbus.emitter.eventNames().includes('__test__.event'));
eventbus.on('__test__.event2', () => {});
// @ts-expect-error private member
t.true(eventbus.emitter.eventNames().includes('__test__.event2'));
});
test('should dispatch event listener', t => {
const { eventbus, listener } = t.context;
const runtimeListener = Sinon.stub();
const off = eventbus.on('__test__.event', runtimeListener);
const payload = { count: 0 };
eventbus.emit('__test__.event', payload);
t.true(listener.calledOnceWithExactly(payload));
t.true(runtimeListener.calledOnceWithExactly(payload));
off();
});
test('should dispatch async event listener', async t => {
const { eventbus, listener } = t.context;
const runtimeListener = Sinon.stub().returns({ count: 2 });
const off = eventbus.on('__test__.event', runtimeListener);
const payload = { count: 0 };
const returns = await eventbus.emitAsync('__test__.event', payload);
t.true(listener.calledOnceWithExactly(payload));
t.true(runtimeListener.calledOnceWithExactly(payload));
t.deepEqual(returns, [{ count: 1 }, { count: 2 }]);
off();
});
test('should record event handler call metrics', async t => {
const { eventbus } = t.context;
const timerStub = Sinon.stub(
metrics.event.histogram('function_timer'),
'record'
);
const counterStub = Sinon.stub(
metrics.event.counter('function_calls'),
'add'
);
await eventbus.emitAsync('__test__.event', { count: 0 });
t.deepEqual(timerStub.getCall(0).args[1], {
name: 'event_handler',
event: '__test__.event',
namespace: '__test__',
error: false,
});
t.deepEqual(counterStub.getCall(0).args[1], {
event: '__test__.event',
namespace: '__test__',
});
Sinon.reset();
await eventbus.emitAsync('__test__.throw', { count: 0 });
t.deepEqual(timerStub.getCall(0).args[1], {
name: 'event_handler',
event: '__test__.throw',
namespace: '__test__',
error: true,
});
});
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '../../base';
declare global {
interface Events {
'__test__.event': { count: number };
'__test__.event2': { count: number };
'__test__.throw': { count: number };
}
}
@Injectable()
export class Listeners {
@OnEvent('__test__.event')
onTestEvent({ count }: Events['__test__.event']) {
return {
count: count + 1,
};
}
@OnEvent('__test__.throw')
onThrow() {
throw new Error('Error in event handler');
}
}
@@ -1,10 +1,9 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { TestingModule } from '@nestjs/testing'; import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { EmailAlreadyUsed } from '../../base'; import { EmailAlreadyUsed, EventBus } from '../../base';
import { Permission } from '../../models/common'; import { Permission } from '../../models/common';
import { UserModel } from '../../models/user'; import { UserModel } from '../../models/user';
import { WorkspaceMemberStatus } from '../../models/workspace'; import { WorkspaceMemberStatus } from '../../models/workspace';
@@ -46,7 +45,7 @@ test('should create a new user', async t => {
}); });
test('should trigger user.created event', async t => { test('should trigger user.created event', async t => {
const event = t.context.module.get(EventEmitter2); const event = t.context.module.get(EventBus);
const spy = Sinon.spy(); const spy = Sinon.spy();
event.on('user.created', spy); event.on('user.created', spy);
@@ -117,7 +116,7 @@ test('should not update email to an existing one', async t => {
}); });
test('should trigger user.updated event', async t => { test('should trigger user.updated event', async t => {
const event = t.context.module.get(EventEmitter2); const event = t.context.module.get(EventBus);
const spy = Sinon.spy(); const spy = Sinon.spy();
event.on('user.updated', spy); event.on('user.updated', spy);
@@ -217,7 +216,7 @@ test('should fulfill user', async t => {
}); });
test('should trigger user.updated event when fulfilling user', async t => { test('should trigger user.updated event when fulfilling user', async t => {
const event = t.context.module.get(EventEmitter2); const event = t.context.module.get(EventBus);
const createSpy = Sinon.spy(); const createSpy = Sinon.spy();
const updateSpy = Sinon.spy(); const updateSpy = Sinon.spy();
event.on('user.created', createSpy); event.on('user.created', createSpy);
@@ -250,7 +249,7 @@ test('should delete user', async t => {
}); });
test('should trigger user.deleted event', async t => { test('should trigger user.deleted event', async t => {
const event = t.context.module.get(EventEmitter2); const event = t.context.module.get(EventBus);
const spy = Sinon.spy(); const spy = Sinon.spy();
event.on('user.deleted', spy); event.on('user.deleted', spy);
@@ -1,10 +1,9 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { TestingModule } from '@nestjs/testing'; import { TestingModule } from '@nestjs/testing';
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { Config } from '../../base/config'; import { Config, EventBus } from '../../base';
import { Permission } from '../../models/common'; import { Permission } from '../../models/common';
import { UserModel } from '../../models/user'; import { UserModel } from '../../models/user';
import { WorkspaceModel } from '../../models/workspace'; import { WorkspaceModel } from '../../models/workspace';
@@ -305,7 +304,7 @@ test('should grant member with read permission and Pending status by default', a
email: 'test2@affine.pro', email: 'test2@affine.pro',
}); });
const event = t.context.module.get(EventEmitter2); const event = t.context.module.get(EventBus);
const updatedSpy = Sinon.spy(); const updatedSpy = Sinon.spy();
event.on('workspace.members.updated', updatedSpy); event.on('workspace.members.updated', updatedSpy);
const member1 = await t.context.workspace.grantMember( const member1 = await t.context.workspace.grantMember(
@@ -829,7 +828,7 @@ test('should delete workspace member in Pending, Accepted status', async t => {
); );
t.is(member.status, WorkspaceMemberStatus.Pending); t.is(member.status, WorkspaceMemberStatus.Pending);
const event = t.context.module.get(EventEmitter2); const event = t.context.module.get(EventBus);
const updatedSpy = Sinon.spy(); const updatedSpy = Sinon.spy();
event.on('workspace.members.updated', updatedSpy); event.on('workspace.members.updated', updatedSpy);
let success = await t.context.workspace.deleteMember( let success = await t.context.workspace.deleteMember(
@@ -880,7 +879,7 @@ test('should trigger workspace.members.requestDeclined event when delete workspa
); );
t.is(member.status, WorkspaceMemberStatus.UnderReview); t.is(member.status, WorkspaceMemberStatus.UnderReview);
const event = t.context.module.get(EventEmitter2); const event = t.context.module.get(EventBus);
const updatedSpy = Sinon.spy(); const updatedSpy = Sinon.spy();
const requestDeclinedSpy = Sinon.spy(); const requestDeclinedSpy = Sinon.spy();
event.on('workspace.members.updated', updatedSpy); event.on('workspace.members.updated', updatedSpy);
@@ -925,7 +924,7 @@ test('should trigger workspace.members.requestDeclined event when delete workspa
); );
t.is(member.status, WorkspaceMemberStatus.NeedMoreSeatAndReview); t.is(member.status, WorkspaceMemberStatus.NeedMoreSeatAndReview);
const event = t.context.module.get(EventEmitter2); const event = t.context.module.get(EventBus);
const updatedSpy = Sinon.spy(); const updatedSpy = Sinon.spy();
const requestDeclinedSpy = Sinon.spy(); const requestDeclinedSpy = Sinon.spy();
event.on('workspace.members.updated', updatedSpy); event.on('workspace.members.updated', updatedSpy);
@@ -105,18 +105,21 @@ function gql(app: INestApplication, query: string) {
.expect(200); .expect(200);
} }
test.beforeEach(async ({ context }) => { test.before(async ({ context }) => {
const { app } = await createTestingApp({ const { app } = await createTestingApp({
providers: [TestResolver, TestGateway], providers: [TestResolver, TestGateway],
controllers: [TestController], controllers: [TestController],
}); });
context.logger = Sinon.stub(new Logger().localInstance); context.logger = Sinon.stub(new Logger().localInstance);
context.app = app; context.app = app;
}); });
test.afterEach.always(async ctx => { test.beforeEach(() => {
Sinon.reset();
});
test.after.always(async ctx => {
await ctx.context.app.close(); await ctx.context.app.close();
}); });
@@ -131,6 +134,7 @@ test('should be able to handle known user error in graphql query', async t => {
t.is(err.message, 'You do not have permission to access this resource.'); t.is(err.message, 'You do not have permission to access this resource.');
t.is(err.extensions.status, HttpStatus.FORBIDDEN); t.is(err.extensions.status, HttpStatus.FORBIDDEN);
t.is(err.extensions.name, 'ACCESS_DENIED'); t.is(err.extensions.name, 'ACCESS_DENIED');
// console.log(t.context.logger.error.getCalls());
t.true(t.context.logger.error.notCalled); t.true(t.context.logger.error.notCalled);
}); });
@@ -7,7 +7,7 @@ import Sinon from 'sinon';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { AppModule } from '../../app.module'; import { AppModule } from '../../app.module';
import { EventEmitter, Runtime } from '../../base'; import { EventBus, Runtime } from '../../base';
import { ConfigModule } from '../../base/config'; import { ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service'; import { AuthService } from '../../core/auth/service';
@@ -158,7 +158,7 @@ const test = ava as TestFn<{
db: PrismaClient; db: PrismaClient;
app: INestApplication; app: INestApplication;
service: SubscriptionService; service: SubscriptionService;
event: Sinon.SinonStubbedInstance<EventEmitter>; event: Sinon.SinonStubbedInstance<EventBus>;
feature: Sinon.SinonStubbedInstance<FeatureManagementService>; feature: Sinon.SinonStubbedInstance<FeatureManagementService>;
runtime: Sinon.SinonStubbedInstance<Runtime>; runtime: Sinon.SinonStubbedInstance<Runtime>;
stripe: { stripe: {
@@ -203,14 +203,12 @@ test.before(async t => {
m.overrideProvider(FeatureManagementService).useValue( m.overrideProvider(FeatureManagementService).useValue(
Sinon.createStubInstance(FeatureManagementService) Sinon.createStubInstance(FeatureManagementService)
); );
m.overrideProvider(EventEmitter).useValue( m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
Sinon.createStubInstance(EventEmitter)
);
m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime)); m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime));
}, },
}); });
t.context.event = app.get(EventEmitter); t.context.event = app.get(EventBus);
t.context.service = app.get(SubscriptionService); t.context.service = app.get(SubscriptionService);
t.context.feature = app.get(FeatureManagementService); t.context.feature = app.get(FeatureManagementService);
t.context.runtime = app.get(Runtime); t.context.runtime = app.get(Runtime);
@@ -10,7 +10,7 @@ import ava from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import { AppModule } from '../app.module'; import { AppModule } from '../app.module';
import { EventEmitter } from '../base'; import { EventBus } from '../base';
import { AuthService } from '../core/auth'; import { AuthService } from '../core/auth';
import { DocContentService } from '../core/doc-renderer'; import { DocContentService } from '../core/doc-renderer';
import { Permission, PermissionService } from '../core/permission'; import { Permission, PermissionService } from '../core/permission';
@@ -41,7 +41,7 @@ import {
const test = ava as TestFn<{ const test = ava as TestFn<{
app: INestApplication; app: INestApplication;
auth: AuthService; auth: AuthService;
event: Sinon.SinonStubbedInstance<EventEmitter>; event: Sinon.SinonStubbedInstance<EventBus>;
quota: QuotaService; quota: QuotaService;
quotaManager: QuotaManagementService; quotaManager: QuotaManagementService;
permissions: PermissionService; permissions: PermissionService;
@@ -52,8 +52,8 @@ test.beforeEach(async t => {
imports: [AppModule], imports: [AppModule],
tapModule: module => { tapModule: module => {
module module
.overrideProvider(EventEmitter) .overrideProvider(EventBus)
.useValue(Sinon.createStubInstance(EventEmitter)); .useValue(Sinon.createStubInstance(EventBus));
module.overrideProvider(DocContentService).useValue({ module.overrideProvider(DocContentService).useValue({
getWorkspaceContent() { getWorkspaceContent() {
return { return {
@@ -67,7 +67,7 @@ test.beforeEach(async t => {
t.context.app = app; t.context.app = app;
t.context.auth = app.get(AuthService); t.context.auth = app.get(AuthService);
t.context.event = app.get(EventEmitter); t.context.event = app.get(EventBus);
t.context.quota = app.get(QuotaService); t.context.quota = app.get(QuotaService);
t.context.quotaManager = app.get(QuotaManagementService); t.context.quotaManager = app.get(QuotaManagementService);
t.context.permissions = app.get(PermissionService); t.context.permissions = app.get(PermissionService);
@@ -112,7 +112,12 @@ export async function createTestingModule(
if (init) { if (init) {
await m.init(); await m.init();
// we got a lot smoking tests try to break nestjs
// can't tolerate the noisy logs
// @ts-expect-error private
m.applyLogger({
logger: ['fatal'],
});
const runtime = m.get(Runtime); const runtime = m.get(Runtime);
// by pass password min length validation // by pass password min length validation
await runtime.set('auth/password.min', 1); await runtime.set('auth/password.min', 1);
@@ -128,7 +133,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
cors: true, cors: true,
bodyParser: true, bodyParser: true,
rawBody: true, rawBody: true,
logger: ['warn'], logger: ['fatal'],
}); });
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter())); app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
+1 -1
View File
@@ -97,6 +97,7 @@ export const FunctionalityModules = [
HelpersModule, HelpersModule,
ErrorModule, ErrorModule,
LoggerModule, LoggerModule,
WebSocketModule,
]; ];
function filterOptionalModule( function filterOptionalModule(
@@ -197,7 +198,6 @@ export function buildAppModule() {
// basic // basic
.use(...FunctionalityModules) .use(...FunctionalityModules)
.use(ModelsModule) .use(ModelsModule)
.useIf(config => config.flavor.sync, WebSocketModule)
// auth // auth
.use(UserModule, AuthModule, PermissionModule) .use(UserModule, AuthModule, PermissionModule)
+2 -4
View File
@@ -45,10 +45,8 @@ export async function createApp() {
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter())); app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
app.use(cookieParser()); app.use(cookieParser());
if (AFFiNE.flavor.sync) { const adapter = new SocketIoAdapter(app);
const adapter = new SocketIoAdapter(app); app.useWebSocketAdapter(adapter);
app.useWebSocketAdapter(adapter);
}
if (AFFiNE.isSelfhosted && AFFiNE.metrics.telemetry.enabled) { if (AFFiNE.isSelfhosted && AFFiNE.metrics.telemetry.enabled) {
const mixpanel = await import('mixpanel'); const mixpanel = await import('mixpanel');
+16 -69
View File
@@ -1,71 +1,18 @@
import type { Snapshot, User, Workspace } from '@prisma/client'; declare global {
/**
import { Flatten, Payload } from './types'; * Event definitions can be extended by
*
export interface WorkspaceEvents { * @example
members: { *
reviewRequested: Payload<{ inviteId: string }>; * declare global {
requestDeclined: Payload<{ * interface Events {
userId: User['id']; * 'user.subscription.created': {
workspaceId: Workspace['id']; * userId: User['id'];
}>; * }
requestApproved: Payload<{ inviteId: string }>; * }
roleChanged: Payload<{ * }
userId: User['id']; */
workspaceId: Workspace['id']; interface Events {}
permission: number;
}>;
ownershipTransferred: Payload<{
from: User['id'];
to: User['id'];
workspaceId: Workspace['id'];
}>;
ownershipReceived: Payload<{ workspaceId: Workspace['id'] }>;
updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
leave: Payload<{
user: Pick<User, 'id' | 'email'>;
workspaceId: Workspace['id'];
}>;
removed: Payload<{ workspaceId: Workspace['id']; userId: User['id'] }>;
};
deleted: Payload<Workspace['id']>;
blob: {
deleted: Payload<{
workspaceId: Workspace['id'];
key: string;
}>;
sync: Payload<{
workspaceId: Workspace['id'];
key: string;
}>;
};
} }
export interface DocEvents { export type EventName = keyof Events;
deleted: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
updated: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
}
/**
* Event definitions can be extended by
*
* @example
*
* declare module './event/def' {
* interface UserEvents {
* created: Payload<User>;
* }
* }
*
* assert<Event, 'user.created'>()
*/
export interface EventDefinitions {
workspace: WorkspaceEvents;
snapshot: DocEvents;
}
export type EventKV = Flatten<EventDefinitions>;
export type Event = keyof EventKV;
export type EventPayload<E extends Event> = EventKV[E];
export type { Payload };
@@ -0,0 +1,141 @@
import {
applyDecorators,
Injectable,
Logger,
OnApplicationBootstrap,
} from '@nestjs/common';
import {
EventEmitter2,
EventEmitterReadinessWatcher,
OnEvent as RawOnEvent,
OnEventMetadata,
} from '@nestjs/event-emitter';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import type { Server, Socket } from 'socket.io';
import { CallMetric } from '../metrics';
import type { EventName } from './def';
const EventHandlerWrapper = (event: EventName): MethodDecorator => {
// @ts-expect-error allow
return (
_target,
key,
desc: TypedPropertyDescriptor<(...args: any[]) => any>
) => {
const originalMethod = desc.value;
if (!originalMethod) {
return desc;
}
desc.value = function (...args: any[]) {
new Logger(EventBus.name).log(
`Event handler: ${event} (${key.toString()})`
);
return originalMethod.apply(this, args);
};
};
};
export const OnEvent = (
event: EventName,
opts?: OnEventMetadata['options']
) => {
const namespace = event.split('.')[0];
return applyDecorators(
EventHandlerWrapper(event),
CallMetric('event', 'event_handler', undefined, { event, namespace }),
RawOnEvent(event, opts)
);
};
/**
* We use socket.io system to auto pub/sub on server to server broadcast events
*/
@WebSocketGateway({
namespace: 's2s',
})
@Injectable()
export class EventBus implements OnGatewayConnection, OnApplicationBootstrap {
private readonly logger = new Logger(EventBus.name);
@WebSocketServer()
private readonly server?: Server;
constructor(
private readonly emitter: EventEmitter2,
private readonly watcher: EventEmitterReadinessWatcher
) {}
handleConnection(client: Socket) {
// for internal usage only, disallow any connection from client
this.logger.warn(
`EventBus get suspicious connection from client ${client.id}, disconnecting...`
);
client.disconnect();
}
async onApplicationBootstrap() {
this.watcher
.waitUntilReady()
.then(() => {
const events = this.emitter.eventNames() as EventName[];
events.forEach(event => {
// Proxy all events received from server(trigger by `server.serverSideEmit`)
// to internal event system
this.server?.on(event, payload => {
this.logger.log(`Server Event: ${event} (Received)`);
this.emit(event, payload);
});
});
})
.catch(() => {
// startup time promise, never throw at runtime
});
}
/**
* Emit event to trigger all listeners on current instance
*/
async emitAsync<T extends EventName>(event: T, payload: Events[T]) {
this.logger.log(`Dispatch event: ${event} (async)`);
return await this.emitter.emitAsync(event, payload);
}
/**
* Emit event to trigger all listeners on current instance
*/
emit<T extends EventName>(event: T, payload: Events[T]) {
this.logger.log(`Dispatch event: ${event}`);
return this.emitter.emit(event, payload);
}
/**
* Broadcast event to trigger all listeners on all instance in cluster
*/
broadcast<T extends EventName>(event: T, payload: Events[T]) {
this.logger.log(`Server Event: ${event} (Send)`);
this.server?.serverSideEmit(event, payload);
}
on<T extends EventName>(
event: T,
listener: (payload: Events[T]) => void | Promise<any>,
opts?: OnEventMetadata['options']
) {
this.emitter.on(event, listener as any, opts);
return () => {
this.emitter.off(event, listener as any);
};
}
waitFor<T extends EventName>(name: T, timeout?: number) {
return this.emitter.waitFor(name, timeout);
}
}
@@ -1,43 +1,14 @@
import { Global, Injectable, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { import { EventEmitterModule } from '@nestjs/event-emitter';
EventEmitter2,
EventEmitterModule,
OnEvent as RawOnEvent,
} from '@nestjs/event-emitter';
import type { Event, EventPayload } from './def'; import { EventBus, OnEvent } from './eventbus';
@Injectable()
export class EventEmitter {
constructor(private readonly emitter: EventEmitter2) {}
emit<E extends Event>(event: E, payload: EventPayload<E>) {
return this.emitter.emit(event, payload);
}
emitAsync<E extends Event>(event: E, payload: EventPayload<E>) {
return this.emitter.emitAsync(event, payload);
}
on<E extends Event>(event: E, handler: (payload: EventPayload<E>) => void) {
return this.emitter.on(event, handler);
}
once<E extends Event>(event: E, handler: (payload: EventPayload<E>) => void) {
return this.emitter.once(event, handler);
}
}
export const OnEvent = RawOnEvent as (
event: Event,
opts?: Parameters<typeof RawOnEvent>[1]
) => MethodDecorator;
@Global() @Global()
@Module({ @Module({
imports: [EventEmitterModule.forRoot()], imports: [EventEmitterModule.forRoot({ global: false })],
providers: [EventEmitter], providers: [EventBus],
exports: [EventEmitter], exports: [EventBus],
}) })
export class EventModule {} export class EventModule {}
export { Event, EventPayload };
export { EventBus, OnEvent };
@@ -1,22 +0,0 @@
import type { Join, PathType } from '../utils/types';
export type Payload<T> = {
__payload: true;
data: T;
};
export type Leaves<T, P extends string = ''> =
T extends Record<string, any>
? {
[K in keyof T]: K extends string
? T[K] extends Payload<any>
? K
: Join<K, Leaves<T[K], P>>
: never;
}[keyof T]
: never;
export type Flatten<T extends Record<string, any>> = {
// @ts-expect-error allow
[K in Leaves<T>]: PathType<T, K> extends Payload<infer U> ? U : never;
};
+1 -1
View File
@@ -14,7 +14,7 @@ export {
getAFFiNEConfigModifier, getAFFiNEConfigModifier,
} from './config'; } from './config';
export * from './error'; export * from './error';
export { EventEmitter, type EventPayload, OnEvent } from './event'; export { EventBus, OnEvent } from './event';
export type { GraphqlContext } from './graphql'; export type { GraphqlContext } from './graphql';
export * from './guard'; export * from './guard';
export { CryptoHelper, URLHelper } from './helpers'; export { CryptoHelper, URLHelper } from './helpers';
@@ -37,7 +37,8 @@ export type KnownMetricScopes =
| 'doc' | 'doc'
| 'sse' | 'sse'
| 'mail' | 'mail'
| 'ai'; | 'ai'
| 'event';
const metricCreators: MetricCreators = { const metricCreators: MetricCreators = {
counter(meter: Meter, name: string, opts?: MetricOptions) { counter(meter: Meter, name: string, opts?: MetricOptions) {
@@ -1,16 +1,36 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common'; import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { Redis as IORedis, RedisOptions } from 'ioredis'; import { Redis as IORedis, RedisOptions } from 'ioredis';
import { Config } from '../../base/config'; import { Config } from '../../base/config';
class Redis extends IORedis implements OnModuleDestroy { class Redis extends IORedis implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(this.constructor.name);
constructor(opts: RedisOptions) { constructor(opts: RedisOptions) {
super(opts); super(opts);
} }
errorHandler = (err: Error) => {
this.logger.error(err);
};
onModuleInit() {
this.on('error', this.errorHandler);
}
onModuleDestroy() { onModuleDestroy() {
this.disconnect(); this.disconnect();
} }
override duplicate(override?: Partial<RedisOptions>): IORedis {
const client = super.duplicate(override);
client.on('error', this.errorHandler);
return client;
}
} }
@Injectable() @Injectable()
@@ -1,22 +1,7 @@
import { FlattenedAppRuntimeConfig } from '../config/types'; import { FlattenedAppRuntimeConfig } from '../config/types';
import { OnEvent } from '../event';
import { Payload } from '../event/def';
declare module '../event/def' { declare global {
interface EventDefinitions { interface Events {
runtime: { 'runtime.changed__NOT_IMPLEMENTED__': Partial<FlattenedAppRuntimeConfig>;
[K in keyof FlattenedAppRuntimeConfig]: {
changed: Payload<FlattenedAppRuntimeConfig[K]>;
};
};
} }
} }
/**
* not implemented yet
*/
export const OnRuntimeConfigChange_DO_NOT_USE = (
nameWithModule: keyof FlattenedAppRuntimeConfig
) => {
return OnEvent(`runtime.${nameWithModule}.changed`);
};
@@ -39,17 +39,18 @@ export class SocketIoAdapter extends IoAdapter {
} }
const pubClient = this.app.get(SocketIoRedis); const pubClient = this.app.get(SocketIoRedis);
pubClient.on('error', err => {
console.error(err);
});
const subClient = pubClient.duplicate(); const subClient = pubClient.duplicate();
subClient.on('error', err => {
console.error(err);
});
server.adapter(createAdapter(pubClient, subClient)); server.adapter(createAdapter(pubClient, subClient));
const close = server.close;
server.close = async fn => {
await close.call(server, fn);
// NOTE(@forehalo):
// the lifecycle of duplicated redis client will not be controlled by nestjs lifecycle
// we've got to manually disconnect it
subClient.disconnect();
};
return server; return server;
} }
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { applyUpdate, Doc } from 'yjs'; import { applyUpdate, Doc } from 'yjs';
import { Cache, type EventPayload, OnEvent } from '../../base'; import { Cache, OnEvent } from '../../base';
import { PgWorkspaceDocStorageAdapter } from '../doc'; import { PgWorkspaceDocStorageAdapter } from '../doc';
import { import {
type PageDocContent, type PageDocContent,
@@ -78,15 +78,15 @@ export class DocContentService {
return content; return content;
} }
@OnEvent('snapshot.updated') @OnEvent('doc.snapshot.updated')
async markDocContentCacheStale({ async markDocContentCacheStale({
workspaceId, workspaceId,
id, docId,
}: EventPayload<'snapshot.updated'>) { }: Events['doc.snapshot.updated']) {
const key = const key =
workspaceId === id workspaceId === docId
? `workspace:${workspaceId}:content` ? `workspace:${workspaceId}:content`
: `workspace:${workspaceId}:doc:${id}:content`; : `workspace:${workspaceId}:doc:${docId}:content`;
await this.cache.delete(key); await this.cache.delete(key);
} }
} }
@@ -6,7 +6,7 @@ import {
Cache, Cache,
DocHistoryNotFound, DocHistoryNotFound,
DocNotFound, DocNotFound,
EventEmitter, EventBus,
FailedToSaveUpdates, FailedToSaveUpdates,
FailedToUpsertSnapshot, FailedToUpsertSnapshot,
metrics, metrics,
@@ -22,7 +22,18 @@ import {
} from '../storage'; } from '../storage';
const UPDATES_QUEUE_CACHE_KEY = 'doc:manager:updates'; const UPDATES_QUEUE_CACHE_KEY = 'doc:manager:updates';
declare global {
interface Events {
'doc.snapshot.deleted': {
workspaceId: string;
docId: string;
};
'doc.snapshot.updated': {
workspaceId: string;
docId: string;
};
}
}
@Injectable() @Injectable()
export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter { export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
private readonly logger = new Logger(PgWorkspaceDocStorageAdapter.name); private readonly logger = new Logger(PgWorkspaceDocStorageAdapter.name);
@@ -31,7 +42,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
private readonly db: PrismaClient, private readonly db: PrismaClient,
private readonly mutex: Mutex, private readonly mutex: Mutex,
private readonly cache: Cache, private readonly cache: Cache,
private readonly event: EventEmitter, private readonly event: EventBus,
protected override readonly options: DocStorageOptions protected override readonly options: DocStorageOptions
) { ) {
super(options); super(options);
@@ -470,9 +481,9 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
const updatedSnapshot = result.at(0); const updatedSnapshot = result.at(0);
if (updatedSnapshot) { if (updatedSnapshot) {
this.event.emit('snapshot.updated', { this.event.emit('doc.snapshot.updated', {
workspaceId: snapshot.spaceId, workspaceId: snapshot.spaceId,
id: snapshot.docId, docId: snapshot.docId,
}); });
} }
+2 -8
View File
@@ -2,13 +2,7 @@ import { Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule'; import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { import { CallMetric, Config, metrics, OnEvent } from '../../base';
CallMetric,
Config,
type EventPayload,
metrics,
OnEvent,
} from '../../base';
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace'; import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
@Injectable() @Injectable()
@@ -81,7 +75,7 @@ export class DocStorageCronJob implements OnModuleInit {
} }
@OnEvent('user.deleted') @OnEvent('user.deleted')
async clearUserWorkspaces(payload: EventPayload<'user.deleted'>) { async clearUserWorkspaces(payload: Events['user.deleted']) {
for (const workspace of payload.ownedWorkspaces) { for (const workspace of payload.ownedWorkspaces) {
await this.workspace.deleteSpace(workspace); await this.workspace.deleteSpace(workspace);
} }
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { type EventPayload, OnEvent, Runtime } from '../../base'; import { Runtime } from '../../base';
import { Models } from '../../models'; import { Models } from '../../models';
import { FeatureService } from './service'; import { FeatureService } from './service';
import { FeatureType } from './types'; import { FeatureType } from './types';
@@ -167,9 +167,4 @@ export class FeatureManagementService {
async listFeatureWorkspaces(feature: FeatureType) { async listFeatureWorkspaces(feature: FeatureType) {
return this.feature.listWorkspacesByFeature(feature); return this.feature.listWorkspacesByFeature(feature);
} }
@OnEvent('user.admin.created')
async onAdminUserCreated({ id }: EventPayload<'user.admin.created'>) {
await this.addAdmin(id);
}
} }
@@ -5,7 +5,7 @@ import { groupBy } from 'lodash-es';
import { import {
DocAccessDenied, DocAccessDenied,
EventEmitter, EventBus,
SpaceAccessDenied, SpaceAccessDenied,
SpaceOwnerNotFound, SpaceOwnerNotFound,
} from '../../base'; } from '../../base';
@@ -15,7 +15,7 @@ import { Permission, PublicPageMode } from './types';
export class PermissionService { export class PermissionService {
constructor( constructor(
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly event: EventEmitter private readonly event: EventBus
) {} ) {}
private get acceptedCondition() { private get acceptedCondition() {
@@ -3,7 +3,6 @@ import type { Request, Response } from 'express';
import { import {
ActionForbidden, ActionForbidden,
EventEmitter,
InternalServerError, InternalServerError,
Mutex, Mutex,
PasswordRequired, PasswordRequired,
@@ -24,7 +23,6 @@ export class CustomSetupController {
constructor( constructor(
private readonly models: Models, private readonly models: Models,
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly event: EventEmitter,
private readonly mutex: Mutex, private readonly mutex: Mutex,
private readonly server: ServerService, private readonly server: ServerService,
private readonly runtime: Runtime private readonly runtime: Runtime
@@ -62,7 +60,6 @@ export class CustomSetupController {
if (!lock) { if (!lock) {
throw new InternalServerError(); throw new InternalServerError();
} }
const user = await this.models.user.create({ const user = await this.models.user.create({
email: input.email, email: input.email,
password: input.password, password: input.password,
@@ -70,7 +67,12 @@ export class CustomSetupController {
}); });
try { try {
await this.event.emitAsync('user.admin.created', user); await this.models.userFeature.add(
user.id,
'administrator',
'selfhost setup'
);
await this.auth.setCookies(req, res, user.id); await this.auth.setCookies(req, res, user.id);
res.send({ id: user.id, email: user.email, name: user.name }); res.send({ id: user.id, email: user.email, name: user.name });
} catch (e) { } catch (e) {
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import type { import type {
BlobInputType, BlobInputType,
EventPayload,
PutObjectMetadata, PutObjectMetadata,
StorageProvider, StorageProvider,
} from '../../../base'; } from '../../../base';
@@ -47,7 +46,7 @@ export class AvatarStorage {
} }
@OnEvent('user.deleted') @OnEvent('user.deleted')
async onUserDeleted(user: EventPayload<'user.deleted'>) { async onUserDeleted(user: Events['user.deleted']) {
if (user.avatarUrl) { if (user.avatarUrl) {
await this.delete(user.avatarUrl); await this.delete(user.avatarUrl);
} }
@@ -4,8 +4,7 @@ import { PrismaClient } from '@prisma/client';
import { import {
autoMetadata, autoMetadata,
Config, Config,
EventEmitter, EventBus,
type EventPayload,
type GetObjectMetadata, type GetObjectMetadata,
ListObjectsMetadata, ListObjectsMetadata,
OnEvent, OnEvent,
@@ -21,7 +20,7 @@ export class WorkspaceBlobStorage {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private readonly event: EventEmitter, private readonly event: EventBus,
private readonly storageFactory: StorageProviderFactory, private readonly storageFactory: StorageProviderFactory,
private readonly db: PrismaClient private readonly db: PrismaClient
) { ) {
@@ -105,7 +104,7 @@ export class WorkspaceBlobStorage {
}); });
deletedBlobs.forEach(blob => { deletedBlobs.forEach(blob => {
this.event.emit('workspace.blob.deleted', { this.event.emit('workspace.blob.delete', {
workspaceId: workspaceId, workspaceId: workspaceId,
key: blob.key, key: blob.key,
}); });
@@ -152,10 +151,7 @@ export class WorkspaceBlobStorage {
} }
@OnEvent('workspace.blob.sync') @OnEvent('workspace.blob.sync')
async syncBlobMeta({ async syncBlobMeta({ workspaceId, key }: Events['workspace.blob.sync']) {
workspaceId,
key,
}: EventPayload<'workspace.blob.sync'>) {
try { try {
const meta = await this.provider.head(`${workspaceId}/${key}`); const meta = await this.provider.head(`${workspaceId}/${key}`);
@@ -176,23 +172,23 @@ export class WorkspaceBlobStorage {
} }
@OnEvent('workspace.deleted') @OnEvent('workspace.deleted')
async onWorkspaceDeleted(workspaceId: EventPayload<'workspace.deleted'>) { async onWorkspaceDeleted({ id }: Events['workspace.deleted']) {
const blobs = await this.list(workspaceId); const blobs = await this.list(id);
// to reduce cpu time holding // to reduce cpu time holding
blobs.forEach(blob => { blobs.forEach(blob => {
this.event.emit('workspace.blob.deleted', { this.event.emit('workspace.blob.delete', {
workspaceId: workspaceId, workspaceId: id,
key: blob.key, key: blob.key,
}); });
}); });
} }
@OnEvent('workspace.blob.deleted') @OnEvent('workspace.blob.delete')
async onDeleteWorkspaceBlob({ async onDeleteWorkspaceBlob({
workspaceId, workspaceId,
key, key,
}: EventPayload<'workspace.blob.deleted'>) { }: Events['workspace.blob.delete']) {
await this.delete(workspaceId, key, true); await this.delete(workspaceId, key, true);
} }
} }
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Config, type EventPayload, OnEvent } from '../../base'; import { Config, OnEvent } from '../../base';
@Injectable() @Injectable()
export class UserEventsListener { export class UserEventsListener {
@@ -9,7 +9,7 @@ export class UserEventsListener {
constructor(private readonly config: Config) {} constructor(private readonly config: Config) {}
@OnEvent('user.updated') @OnEvent('user.updated')
async onUserUpdated(user: EventPayload<'user.updated'>) { async onUserUpdated(user: Events['user.updated']) {
const { enabled, customerIo } = this.config.metrics; const { enabled, customerIo } = this.config.metrics;
if (enabled && customerIo?.token) { if (enabled && customerIo?.token) {
const payload = { const payload = {
@@ -33,7 +33,7 @@ export class UserEventsListener {
} }
@OnEvent('user.deleted') @OnEvent('user.deleted')
async onUserDeleted(user: EventPayload<'user.deleted'>) { async onUserDeleted(user: Events['user.deleted']) {
const { enabled, customerIo } = this.config.metrics; const { enabled, customerIo } = this.config.metrics;
if (enabled && customerIo?.token) { if (enabled && customerIo?.token) {
try { try {
@@ -7,7 +7,6 @@ import {
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import type { User } from '@prisma/client'; import type { User } from '@prisma/client';
import type { Payload } from '../../base/event/def';
import { type CurrentUser } from '../auth/session'; import { type CurrentUser } from '../auth/session';
@ObjectType() @ObjectType()
@@ -91,11 +90,3 @@ export class ManageUserInput {
@Field({ description: 'User name', nullable: true }) @Field({ description: 'User name', nullable: true })
name?: string; name?: string;
} }
declare module '../../base/event/def' {
interface UserEvents {
admin: {
created: Payload<{ id: string }>;
};
}
}
@@ -4,7 +4,6 @@ import { getStreamAsBuffer } from 'get-stream';
import { import {
Cache, Cache,
type EventPayload,
MailService, MailService,
OnEvent, OnEvent,
URLHelper, URLHelper,
@@ -256,7 +255,7 @@ export class WorkspaceService {
async onMemberLeave({ async onMemberLeave({
user, user,
workspaceId, workspaceId,
}: EventPayload<'workspace.members.leave'>) { }: Events['workspace.members.leave']) {
const workspace = await this.getWorkspaceInfo(workspaceId); const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.permission.getWorkspaceOwner(workspaceId); const owner = await this.permission.getWorkspaceOwner(workspaceId);
await this.mailer.sendMemberLeaveEmail(owner.email, { await this.mailer.sendMemberLeaveEmail(owner.email, {
@@ -269,7 +268,7 @@ export class WorkspaceService {
async onMemberRemoved({ async onMemberRemoved({
userId, userId,
workspaceId, workspaceId,
}: EventPayload<'workspace.members.requestDeclined'>) { }: Events['workspace.members.requestDeclined']) {
const user = await this.models.user.get(userId); const user = await this.models.user.get(userId);
if (!user) return; if (!user) return;
@@ -11,8 +11,7 @@ import { nanoid } from 'nanoid';
import { import {
Cache, Cache,
EventEmitter, EventBus,
type EventPayload,
MemberNotFoundInSpace, MemberNotFoundInSpace,
OnEvent, OnEvent,
RequestMutex, RequestMutex,
@@ -43,7 +42,7 @@ export class TeamWorkspaceResolver {
constructor( constructor(
private readonly cache: Cache, private readonly cache: Cache,
private readonly event: EventEmitter, private readonly event: EventBus,
private readonly url: URLHelper, private readonly url: URLHelper,
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
@@ -344,7 +343,7 @@ export class TeamWorkspaceResolver {
@OnEvent('workspace.members.reviewRequested') @OnEvent('workspace.members.reviewRequested')
async onReviewRequested({ async onReviewRequested({
inviteId, inviteId,
}: EventPayload<'workspace.members.reviewRequested'>) { }: Events['workspace.members.reviewRequested']) {
// send review request mail to owner and admin // send review request mail to owner and admin
await this.workspaceService.sendReviewRequestedEmail(inviteId); await this.workspaceService.sendReviewRequestedEmail(inviteId);
} }
@@ -352,7 +351,7 @@ export class TeamWorkspaceResolver {
@OnEvent('workspace.members.requestApproved') @OnEvent('workspace.members.requestApproved')
async onApproveRequest({ async onApproveRequest({
inviteId, inviteId,
}: EventPayload<'workspace.members.requestApproved'>) { }: Events['workspace.members.requestApproved']) {
// send approve mail // send approve mail
await this.workspaceService.sendReviewApproveEmail(inviteId); await this.workspaceService.sendReviewApproveEmail(inviteId);
} }
@@ -361,7 +360,7 @@ export class TeamWorkspaceResolver {
async onDeclineRequest({ async onDeclineRequest({
userId, userId,
workspaceId, workspaceId,
}: EventPayload<'workspace.members.requestDeclined'>) { }: Events['workspace.members.requestDeclined']) {
const user = await this.models.user.getPublicUser(userId); const user = await this.models.user.getPublicUser(userId);
// send decline mail // send decline mail
await this.workspaceService.sendReviewDeclinedEmail( await this.workspaceService.sendReviewDeclinedEmail(
@@ -375,7 +374,7 @@ export class TeamWorkspaceResolver {
userId, userId,
workspaceId, workspaceId,
permission, permission,
}: EventPayload<'workspace.members.roleChanged'>) { }: Events['workspace.members.roleChanged']) {
// send role changed mail // send role changed mail
await this.workspaceService.sendRoleChangedEmail(userId, { await this.workspaceService.sendRoleChangedEmail(userId, {
id: workspaceId, id: workspaceId,
@@ -388,7 +387,7 @@ export class TeamWorkspaceResolver {
workspaceId, workspaceId,
from, from,
to, to,
}: EventPayload<'workspace.members.ownershipTransferred'>) { }: Events['workspace.members.ownershipTransferred']) {
// send ownership transferred mail // send ownership transferred mail
const fromUser = await this.models.user.getPublicUser(from); const fromUser = await this.models.user.getPublicUser(from);
const toUser = await this.models.user.getPublicUser(to); const toUser = await this.models.user.getPublicUser(to);
@@ -18,7 +18,7 @@ import {
AlreadyInSpace, AlreadyInSpace,
Cache, Cache,
DocNotFound, DocNotFound,
EventEmitter, EventBus,
InternalServerError, InternalServerError,
MemberQuotaExceeded, MemberQuotaExceeded,
QueryTooLong, QueryTooLong,
@@ -83,7 +83,7 @@ export class WorkspaceResolver {
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly quota: QuotaManagementService, private readonly quota: QuotaManagementService,
private readonly models: Models, private readonly models: Models,
private readonly event: EventEmitter, private readonly event: EventBus,
private readonly mutex: RequestMutex, private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService, private readonly workspaceService: WorkspaceService,
private readonly workspaceStorage: PgWorkspaceDocStorageAdapter private readonly workspaceStorage: PgWorkspaceDocStorageAdapter
@@ -389,7 +389,7 @@ export class WorkspaceResolver {
}); });
await this.workspaceStorage.deleteSpace(id); await this.workspaceStorage.deleteSpace(id);
this.event.emit('workspace.deleted', id); this.event.emit('workspace.deleted', { id });
return true; return true;
} }
+11 -18
View File
@@ -5,11 +5,10 @@ import { pick } from 'lodash-es';
import { import {
CryptoHelper, CryptoHelper,
EmailAlreadyUsed, EmailAlreadyUsed,
EventEmitter, EventBus,
WrongSignInCredentials, WrongSignInCredentials,
WrongSignInMethod, WrongSignInMethod,
} from '../base'; } from '../base';
import type { Payload } from '../base/event/def';
import { Quota_FreePlanV1_1 } from '../core/quota/schema'; import { Quota_FreePlanV1_1 } from '../core/quota/schema';
import { BaseModel } from './base'; import { BaseModel } from './base';
import type { Workspace } from './workspace'; import type { Workspace } from './workspace';
@@ -40,21 +39,15 @@ const defaultUserCreatingData = {
}, },
}; };
declare module '../base/event/def' { declare global {
interface UserEvents { interface Events {
created: Payload<User>; 'user.created': User;
updated: Payload<User>; 'user.updated': User;
deleted: Payload< 'user.deleted': User & {
User & { // TODO(@forehalo): unlink foreign key constraint on [WorkspaceUserPermission] to delegate
// TODO(@forehalo): unlink foreign key constraint on [WorkspaceUserPermission] to delegate // dealing of owned workspaces of deleted users to workspace model
// dealing of owned workspaces of deleted users to workspace model ownedWorkspaces: Workspace['id'][];
ownedWorkspaces: Workspace['id'][]; };
}
>;
}
interface EventDefinitions {
user: UserEvents;
} }
} }
@@ -65,7 +58,7 @@ export type { User };
export class UserModel extends BaseModel { export class UserModel extends BaseModel {
constructor( constructor(
private readonly crypto: CryptoHelper, private readonly crypto: CryptoHelper,
private readonly event: EventEmitter private readonly event: EventBus
) { ) {
super(); super();
} }
@@ -7,10 +7,57 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { groupBy } from 'lodash-es'; import { groupBy } from 'lodash-es';
import { EventEmitter } from '../base'; import { EventBus } from '../base';
import { BaseModel } from './base'; import { BaseModel } from './base';
import { Permission } from './common'; import { Permission } from './common';
declare global {
interface Events {
'workspace.members.reviewRequested': { inviteId: string };
'workspace.members.requestDeclined': {
userId: string;
workspaceId: string;
};
'workspace.members.requestApproved': { inviteId: string };
'workspace.members.roleChanged': {
userId: string;
workspaceId: string;
permission: number;
};
'workspace.members.ownershipTransferred': {
from: string;
to: string;
workspaceId: string;
};
'workspace.members.updated': {
workspaceId: string;
count: number;
};
'workspace.members.leave': {
user: {
id: string;
email: string;
};
workspaceId: string;
};
'workspace.members.removed': {
workspaceId: string;
userId: string;
};
'workspace.deleted': {
id: string;
};
'workspace.blob.delete': {
workspaceId: string;
key: string;
};
'workspace.blob.sync': {
workspaceId: string;
key: string;
};
}
}
export { WorkspaceMemberStatus }; export { WorkspaceMemberStatus };
export type { Workspace }; export type { Workspace };
export type UpdateWorkspaceInput = Pick< export type UpdateWorkspaceInput = Pick<
@@ -28,7 +75,7 @@ export interface FindWorkspaceMembersOptions {
@Injectable() @Injectable()
export class WorkspaceModel extends BaseModel { export class WorkspaceModel extends BaseModel {
constructor(private readonly event: EventEmitter) { constructor(private readonly event: EventBus) {
super(); super();
} }
@@ -3,8 +3,8 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { InstalledLicense, PrismaClient } from '@prisma/client'; import { InstalledLicense, PrismaClient } from '@prisma/client';
import { import {
EventEmitter, Config,
type EventPayload, EventBus,
InternalServerError, InternalServerError,
LicenseNotFound, LicenseNotFound,
OnEvent, OnEvent,
@@ -27,9 +27,10 @@ export class LicenseService {
private readonly logger = new Logger(LicenseService.name); private readonly logger = new Logger(LicenseService.name);
constructor( constructor(
private readonly config: Config,
private readonly db: PrismaClient, private readonly db: PrismaClient,
private readonly quota: QuotaManagementService, private readonly quota: QuotaManagementService,
private readonly event: EventEmitter, private readonly event: EventBus,
private readonly permission: PermissionService private readonly permission: PermissionService
) {} ) {}
@@ -151,7 +152,11 @@ export class LicenseService {
} }
@OnEvent('workspace.members.updated') @OnEvent('workspace.members.updated')
async updateTeamSeats(payload: EventPayload<'workspace.members.updated'>) { async updateTeamSeats(payload: Events['workspace.members.updated']) {
if (!this.config.isSelfhosted) {
return;
}
const { workspaceId, count } = payload; const { workspaceId, count } = payload;
const license = await this.db.installedLicense.findUnique({ const license = await this.db.installedLicense.findUnique({
@@ -308,7 +313,7 @@ export class LicenseService {
plan, plan,
recurring, recurring,
quantity, quantity,
}: EventPayload<'workspace.subscription.activated'>) { }: Events['workspace.subscription.activated']) {
switch (plan) { switch (plan) {
case SubscriptionPlan.SelfHostedTeam: case SubscriptionPlan.SelfHostedTeam:
await this.quota.addTeamWorkspace( await this.quota.addTeamWorkspace(
@@ -331,7 +336,7 @@ export class LicenseService {
async onWorkspaceSubscriptionCanceled({ async onWorkspaceSubscriptionCanceled({
workspaceId, workspaceId,
plan, plan,
}: EventPayload<'workspace.subscription.canceled'>) { }: Events['workspace.subscription.canceled']) {
switch (plan) { switch (plan) {
case SubscriptionPlan.SelfHostedTeam: case SubscriptionPlan.SelfHostedTeam:
await this.quota.removeTeamWorkspace(workspaceId); await this.quota.removeTeamWorkspace(workspaceId);
@@ -2,11 +2,10 @@ import assert from 'node:assert';
import type { RawBodyRequest } from '@nestjs/common'; import type { RawBodyRequest } from '@nestjs/common';
import { Controller, Logger, Post, Req } from '@nestjs/common'; import { Controller, Logger, Post, Req } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { Request } from 'express'; import type { Request } from 'express';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { Config, InternalServerError } from '../../base'; import { Config, EventBus, InternalServerError } from '../../base';
import { Public } from '../../core/auth'; import { Public } from '../../core/auth';
@Controller('/api/stripe') @Controller('/api/stripe')
@@ -17,7 +16,7 @@ export class StripeWebhookController {
constructor( constructor(
config: Config, config: Config,
private readonly stripe: Stripe, private readonly stripe: Stripe,
private readonly event: EventEmitter2 private readonly event: EventBus
) { ) {
assert(config.plugins.payment.stripe); assert(config.plugins.payment.stripe);
this.webhookKey = config.plugins.payment.stripe.keys.webhookKey; this.webhookKey = config.plugins.payment.stripe.keys.webhookKey;
@@ -41,7 +40,7 @@ export class StripeWebhookController {
// Stripe requires responseing webhook immediately and handle event asynchronously. // Stripe requires responseing webhook immediately and handle event asynchronously.
setImmediate(() => { setImmediate(() => {
this.event.emitAsync(`stripe:${event.type}`, event).catch(e => { this.event.emitAsync(`stripe.${event.type}` as any, event).catch(e => {
this.logger.error('Failed to handle Stripe Webhook event.', e); this.logger.error('Failed to handle Stripe Webhook event.', e);
}); });
}); });
@@ -3,7 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { EventEmitter, type EventPayload } from '../../base'; import { EventBus } from '../../base';
import { import {
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
@@ -14,7 +14,7 @@ import {
export class SubscriptionCronJobs { export class SubscriptionCronJobs {
constructor( constructor(
private readonly db: PrismaClient, private readonly db: PrismaClient,
private readonly event: EventEmitter private readonly event: EventBus
) {} ) {}
private getDateRange(after: number, base: number | Date = Date.now()) { private getDateRange(after: number, base: number | Date = Date.now()) {
@@ -77,14 +77,14 @@ export class SubscriptionCronJobs {
// should not reach here // should not reach here
continue; continue;
} }
this.event.emit('workspace.subscription.notify', {
workspaceId: subscription.targetId, if (!subscription.nextBillAt) {
expirationDate: end, this.event.emit('workspace.subscription.notify', {
deletionDate: workspaceId: subscription.targetId,
subscription.status === 'canceled' expirationDate: end,
? this.getDateRange(180, end).end deletionDate: this.getDateRange(180, end).end,
: undefined, });
}); }
} }
} }
@@ -112,7 +112,7 @@ export class SubscriptionCronJobs {
async handleUserSubscriptionCanceled({ async handleUserSubscriptionCanceled({
userId, userId,
plan, plan,
}: EventPayload<'user.subscription.canceled'>) { }: Events['user.subscription.canceled']) {
await this.db.subscription.delete({ await this.db.subscription.delete({
where: { where: {
targetId_plan: { targetId_plan: {
@@ -5,7 +5,7 @@ import Stripe from 'stripe';
import { z } from 'zod'; import { z } from 'zod';
import { import {
EventEmitter, EventBus,
InternalServerError, InternalServerError,
InvalidCheckoutParameters, InvalidCheckoutParameters,
Runtime, Runtime,
@@ -58,7 +58,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
db: PrismaClient, db: PrismaClient,
private readonly runtime: Runtime, private readonly runtime: Runtime,
private readonly feature: FeatureManagementService, private readonly feature: FeatureManagementService,
private readonly event: EventEmitter, private readonly event: EventBus,
private readonly url: URLHelper private readonly url: URLHelper
) { ) {
super(stripe, db); super(stripe, db);
@@ -5,8 +5,7 @@ import Stripe from 'stripe';
import { z } from 'zod'; import { z } from 'zod';
import { import {
EventEmitter, EventBus,
type EventPayload,
OnEvent, OnEvent,
SubscriptionAlreadyExists, SubscriptionAlreadyExists,
SubscriptionPlanNotFound, SubscriptionPlanNotFound,
@@ -49,7 +48,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
stripe: Stripe, stripe: Stripe,
db: PrismaClient, db: PrismaClient,
private readonly url: URLHelper, private readonly url: URLHelper,
private readonly event: EventEmitter private readonly event: EventBus
) { ) {
super(stripe, db); super(stripe, db);
} }
@@ -269,7 +268,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
async onMembersUpdated({ async onMembersUpdated({
workspaceId, workspaceId,
count, count,
}: EventPayload<'workspace.members.updated'>) { }: Events['workspace.members.updated']) {
const subscription = await this.getSubscription({ const subscription = await this.getSubscription({
plan: SubscriptionPlan.Team, plan: SubscriptionPlan.Team,
workspaceId, workspaceId,
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import type { EventPayload } from '../../base';
import { FeatureManagementService } from '../../core/features'; import { FeatureManagementService } from '../../core/features';
import { PermissionService } from '../../core/permission'; import { PermissionService } from '../../core/permission';
import { import {
@@ -29,7 +28,7 @@ export class QuotaOverride {
plan, plan,
recurring, recurring,
quantity, quantity,
}: EventPayload<'workspace.subscription.activated'>) { }: Events['workspace.subscription.activated']) {
switch (plan) { switch (plan) {
case 'team': { case 'team': {
const hasTeamWorkspace = await this.quota.hasWorkspaceQuota( const hasTeamWorkspace = await this.quota.hasWorkspaceQuota(
@@ -62,7 +61,7 @@ export class QuotaOverride {
async onWorkspaceSubscriptionCanceled({ async onWorkspaceSubscriptionCanceled({
workspaceId, workspaceId,
plan, plan,
}: EventPayload<'workspace.subscription.canceled'>) { }: Events['workspace.subscription.canceled']) {
switch (plan) { switch (plan) {
case SubscriptionPlan.Team: case SubscriptionPlan.Team:
await this.manager.removeTeamWorkspace(workspaceId); await this.manager.removeTeamWorkspace(workspaceId);
@@ -77,7 +76,7 @@ export class QuotaOverride {
userId, userId,
plan, plan,
recurring, recurring,
}: EventPayload<'user.subscription.activated'>) { }: Events['user.subscription.activated']) {
switch (plan) { switch (plan) {
case SubscriptionPlan.AI: case SubscriptionPlan.AI:
await this.feature.addCopilot(userId, 'subscription activated'); await this.feature.addCopilot(userId, 'subscription activated');
@@ -100,7 +99,7 @@ export class QuotaOverride {
async onUserSubscriptionCanceled({ async onUserSubscriptionCanceled({
userId, userId,
plan, plan,
}: EventPayload<'user.subscription.canceled'>) { }: Events['user.subscription.canceled']) {
switch (plan) { switch (plan) {
case SubscriptionPlan.AI: case SubscriptionPlan.AI:
await this.feature.removeCopilot(userId); await this.feature.removeCopilot(userId);
@@ -1,8 +1,6 @@
import type { User, Workspace } from '@prisma/client'; import type { User, Workspace } from '@prisma/client';
import Stripe from 'stripe'; import Stripe from 'stripe';
import type { Payload } from '../../base/event/def';
export enum SubscriptionRecurring { export enum SubscriptionRecurring {
Monthly = 'monthly', Monthly = 'monthly',
Yearly = 'yearly', Yearly = 'yearly',
@@ -50,41 +48,44 @@ export enum CouponType {
ProEarlyAccessAIOneYearFree = 'ai_pro_ea_one_year_free', ProEarlyAccessAIOneYearFree = 'ai_pro_ea_one_year_free',
} }
declare module '../../base/event/def' { declare global {
interface UserEvents { interface Events {
subscription: { 'user.subscription.activated': {
activated: Payload<{ userId: User['id'];
userId: User['id']; plan: SubscriptionPlan;
plan: SubscriptionPlan; recurring: SubscriptionRecurring;
recurring: SubscriptionRecurring; };
}>; 'user.subscription.canceled': {
canceled: Payload<{ userId: User['id'];
userId: User['id']; plan: SubscriptionPlan;
plan: SubscriptionPlan; recurring: SubscriptionRecurring;
recurring: SubscriptionRecurring;
}>;
}; };
}
interface WorkspaceEvents { 'workspace.subscription.activated': {
subscription: { workspaceId: Workspace['id'];
activated: Payload<{ plan: SubscriptionPlan;
workspaceId: Workspace['id']; recurring: SubscriptionRecurring;
plan: SubscriptionPlan; quantity: number;
recurring: SubscriptionRecurring;
quantity: number;
}>;
canceled: Payload<{
workspaceId: Workspace['id'];
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
}>;
notify: Payload<{
workspaceId: Workspace['id'];
expirationDate: Date;
deletionDate: Date | undefined;
}>;
}; };
'workspace.subscription.canceled': {
workspaceId: Workspace['id'];
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
};
'workspace.subscription.notify': {
workspaceId: Workspace['id'];
expirationDate: Date;
deletionDate: Date;
};
'stripe.invoice.created': Stripe.InvoiceCreatedEvent;
'stripe.invoice.updated': Stripe.InvoiceUpdatedEvent;
'stripe.invoice.finalization_failed': Stripe.InvoiceFinalizationFailedEvent;
'stripe.invoice.payment_failed': Stripe.InvoicePaymentFailedEvent;
'stripe.invoice.paid': Stripe.InvoicePaidEvent;
'stripe.customer.subscription.created': Stripe.CustomerSubscriptionCreatedEvent;
'stripe.customer.subscription.updated': Stripe.CustomerSubscriptionUpdatedEvent;
'stripe.customer.subscription.deleted': Stripe.CustomerSubscriptionDeletedEvent;
} }
} }
@@ -1,14 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { OnEvent } from '../../base';
import { SubscriptionService } from './service'; import { SubscriptionService } from './service';
const OnStripeEvent = (
event: Stripe.Event.Type,
opts?: Parameters<typeof OnEvent>[1]
) => OnEvent(`stripe:${event}`, opts);
/** /**
* Stripe webhook events sent in random order, and may be even sent more than once. * Stripe webhook events sent in random order, and may be even sent more than once.
* *
@@ -22,11 +17,11 @@ export class StripeWebhook {
private readonly stripe: Stripe private readonly stripe: Stripe
) {} ) {}
@OnStripeEvent('invoice.created') @OnEvent('stripe.invoice.created')
@OnStripeEvent('invoice.updated') @OnEvent('stripe.invoice.updated')
@OnStripeEvent('invoice.finalization_failed') @OnEvent('stripe.invoice.finalization_failed')
@OnStripeEvent('invoice.payment_failed') @OnEvent('stripe.invoice.payment_failed')
@OnStripeEvent('invoice.paid') @OnEvent('stripe.invoice.paid')
async onInvoiceUpdated( async onInvoiceUpdated(
event: event:
| Stripe.InvoiceCreatedEvent | Stripe.InvoiceCreatedEvent
@@ -39,8 +34,8 @@ export class StripeWebhook {
await this.service.saveStripeInvoice(invoice); await this.service.saveStripeInvoice(invoice);
} }
@OnStripeEvent('customer.subscription.created') @OnEvent('stripe.customer.subscription.created')
@OnStripeEvent('customer.subscription.updated') @OnEvent('stripe.customer.subscription.updated')
async onSubscriptionChanges( async onSubscriptionChanges(
event: event:
| Stripe.CustomerSubscriptionUpdatedEvent | Stripe.CustomerSubscriptionUpdatedEvent
@@ -56,7 +51,7 @@ export class StripeWebhook {
await this.service.saveStripeSubscription(subscription); await this.service.saveStripeSubscription(subscription);
} }
@OnStripeEvent('customer.subscription.deleted') @OnEvent('stripe.customer.subscription.deleted')
async onSubscriptionDeleted(event: Stripe.CustomerSubscriptionDeletedEvent) { async onSubscriptionDeleted(event: Stripe.CustomerSubscriptionDeletedEvent) {
await this.service.deleteStripeSubscription(event.data.object); await this.service.deleteStripeSubscription(event.data.object);
} }