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

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { applyUpdate, Doc } from 'yjs';
import { Cache, type EventPayload, OnEvent } from '../../base';
import { Cache, OnEvent } from '../../base';
import { PgWorkspaceDocStorageAdapter } from '../doc';
import {
type PageDocContent,
@@ -78,15 +78,15 @@ export class DocContentService {
return content;
}
@OnEvent('snapshot.updated')
@OnEvent('doc.snapshot.updated')
async markDocContentCacheStale({
workspaceId,
id,
}: EventPayload<'snapshot.updated'>) {
docId,
}: Events['doc.snapshot.updated']) {
const key =
workspaceId === id
workspaceId === docId
? `workspace:${workspaceId}:content`
: `workspace:${workspaceId}:doc:${id}:content`;
: `workspace:${workspaceId}:doc:${docId}:content`;
await this.cache.delete(key);
}
}

View File

@@ -6,7 +6,7 @@ import {
Cache,
DocHistoryNotFound,
DocNotFound,
EventEmitter,
EventBus,
FailedToSaveUpdates,
FailedToUpsertSnapshot,
metrics,
@@ -22,7 +22,18 @@ import {
} from '../storage';
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()
export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
private readonly logger = new Logger(PgWorkspaceDocStorageAdapter.name);
@@ -31,7 +42,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
private readonly db: PrismaClient,
private readonly mutex: Mutex,
private readonly cache: Cache,
private readonly event: EventEmitter,
private readonly event: EventBus,
protected override readonly options: DocStorageOptions
) {
super(options);
@@ -470,9 +481,9 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
const updatedSnapshot = result.at(0);
if (updatedSnapshot) {
this.event.emit('snapshot.updated', {
this.event.emit('doc.snapshot.updated', {
workspaceId: snapshot.spaceId,
id: snapshot.docId,
docId: snapshot.docId,
});
}

View File

@@ -2,13 +2,7 @@ import { Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import {
CallMetric,
Config,
type EventPayload,
metrics,
OnEvent,
} from '../../base';
import { CallMetric, Config, metrics, OnEvent } from '../../base';
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
@Injectable()
@@ -81,7 +75,7 @@ export class DocStorageCronJob implements OnModuleInit {
}
@OnEvent('user.deleted')
async clearUserWorkspaces(payload: EventPayload<'user.deleted'>) {
async clearUserWorkspaces(payload: Events['user.deleted']) {
for (const workspace of payload.ownedWorkspaces) {
await this.workspace.deleteSpace(workspace);
}

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { type EventPayload, OnEvent, Runtime } from '../../base';
import { Runtime } from '../../base';
import { Models } from '../../models';
import { FeatureService } from './service';
import { FeatureType } from './types';
@@ -167,9 +167,4 @@ export class FeatureManagementService {
async listFeatureWorkspaces(feature: FeatureType) {
return this.feature.listWorkspacesByFeature(feature);
}
@OnEvent('user.admin.created')
async onAdminUserCreated({ id }: EventPayload<'user.admin.created'>) {
await this.addAdmin(id);
}
}

View File

@@ -5,7 +5,7 @@ import { groupBy } from 'lodash-es';
import {
DocAccessDenied,
EventEmitter,
EventBus,
SpaceAccessDenied,
SpaceOwnerNotFound,
} from '../../base';
@@ -15,7 +15,7 @@ import { Permission, PublicPageMode } from './types';
export class PermissionService {
constructor(
private readonly prisma: PrismaClient,
private readonly event: EventEmitter
private readonly event: EventBus
) {}
private get acceptedCondition() {

View File

@@ -3,7 +3,6 @@ import type { Request, Response } from 'express';
import {
ActionForbidden,
EventEmitter,
InternalServerError,
Mutex,
PasswordRequired,
@@ -24,7 +23,6 @@ export class CustomSetupController {
constructor(
private readonly models: Models,
private readonly auth: AuthService,
private readonly event: EventEmitter,
private readonly mutex: Mutex,
private readonly server: ServerService,
private readonly runtime: Runtime
@@ -62,7 +60,6 @@ export class CustomSetupController {
if (!lock) {
throw new InternalServerError();
}
const user = await this.models.user.create({
email: input.email,
password: input.password,
@@ -70,7 +67,12 @@ export class CustomSetupController {
});
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);
res.send({ id: user.id, email: user.email, name: user.name });
} catch (e) {

View File

@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import type {
BlobInputType,
EventPayload,
PutObjectMetadata,
StorageProvider,
} from '../../../base';
@@ -47,7 +46,7 @@ export class AvatarStorage {
}
@OnEvent('user.deleted')
async onUserDeleted(user: EventPayload<'user.deleted'>) {
async onUserDeleted(user: Events['user.deleted']) {
if (user.avatarUrl) {
await this.delete(user.avatarUrl);
}

View File

@@ -4,8 +4,7 @@ import { PrismaClient } from '@prisma/client';
import {
autoMetadata,
Config,
EventEmitter,
type EventPayload,
EventBus,
type GetObjectMetadata,
ListObjectsMetadata,
OnEvent,
@@ -21,7 +20,7 @@ export class WorkspaceBlobStorage {
constructor(
private readonly config: Config,
private readonly event: EventEmitter,
private readonly event: EventBus,
private readonly storageFactory: StorageProviderFactory,
private readonly db: PrismaClient
) {
@@ -105,7 +104,7 @@ export class WorkspaceBlobStorage {
});
deletedBlobs.forEach(blob => {
this.event.emit('workspace.blob.deleted', {
this.event.emit('workspace.blob.delete', {
workspaceId: workspaceId,
key: blob.key,
});
@@ -152,10 +151,7 @@ export class WorkspaceBlobStorage {
}
@OnEvent('workspace.blob.sync')
async syncBlobMeta({
workspaceId,
key,
}: EventPayload<'workspace.blob.sync'>) {
async syncBlobMeta({ workspaceId, key }: Events['workspace.blob.sync']) {
try {
const meta = await this.provider.head(`${workspaceId}/${key}`);
@@ -176,23 +172,23 @@ export class WorkspaceBlobStorage {
}
@OnEvent('workspace.deleted')
async onWorkspaceDeleted(workspaceId: EventPayload<'workspace.deleted'>) {
const blobs = await this.list(workspaceId);
async onWorkspaceDeleted({ id }: Events['workspace.deleted']) {
const blobs = await this.list(id);
// to reduce cpu time holding
blobs.forEach(blob => {
this.event.emit('workspace.blob.deleted', {
workspaceId: workspaceId,
this.event.emit('workspace.blob.delete', {
workspaceId: id,
key: blob.key,
});
});
}
@OnEvent('workspace.blob.deleted')
@OnEvent('workspace.blob.delete')
async onDeleteWorkspaceBlob({
workspaceId,
key,
}: EventPayload<'workspace.blob.deleted'>) {
}: Events['workspace.blob.delete']) {
await this.delete(workspaceId, key, true);
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Config, type EventPayload, OnEvent } from '../../base';
import { Config, OnEvent } from '../../base';
@Injectable()
export class UserEventsListener {
@@ -9,7 +9,7 @@ export class UserEventsListener {
constructor(private readonly config: Config) {}
@OnEvent('user.updated')
async onUserUpdated(user: EventPayload<'user.updated'>) {
async onUserUpdated(user: Events['user.updated']) {
const { enabled, customerIo } = this.config.metrics;
if (enabled && customerIo?.token) {
const payload = {
@@ -33,7 +33,7 @@ export class UserEventsListener {
}
@OnEvent('user.deleted')
async onUserDeleted(user: EventPayload<'user.deleted'>) {
async onUserDeleted(user: Events['user.deleted']) {
const { enabled, customerIo } = this.config.metrics;
if (enabled && customerIo?.token) {
try {

View File

@@ -7,7 +7,6 @@ import {
} from '@nestjs/graphql';
import type { User } from '@prisma/client';
import type { Payload } from '../../base/event/def';
import { type CurrentUser } from '../auth/session';
@ObjectType()
@@ -91,11 +90,3 @@ export class ManageUserInput {
@Field({ description: 'User name', nullable: true })
name?: string;
}
declare module '../../base/event/def' {
interface UserEvents {
admin: {
created: Payload<{ id: string }>;
};
}
}

View File

@@ -4,7 +4,6 @@ import { getStreamAsBuffer } from 'get-stream';
import {
Cache,
type EventPayload,
MailService,
OnEvent,
URLHelper,
@@ -256,7 +255,7 @@ export class WorkspaceService {
async onMemberLeave({
user,
workspaceId,
}: EventPayload<'workspace.members.leave'>) {
}: Events['workspace.members.leave']) {
const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.permission.getWorkspaceOwner(workspaceId);
await this.mailer.sendMemberLeaveEmail(owner.email, {
@@ -269,7 +268,7 @@ export class WorkspaceService {
async onMemberRemoved({
userId,
workspaceId,
}: EventPayload<'workspace.members.requestDeclined'>) {
}: Events['workspace.members.requestDeclined']) {
const user = await this.models.user.get(userId);
if (!user) return;

View File

@@ -11,8 +11,7 @@ import { nanoid } from 'nanoid';
import {
Cache,
EventEmitter,
type EventPayload,
EventBus,
MemberNotFoundInSpace,
OnEvent,
RequestMutex,
@@ -43,7 +42,7 @@ export class TeamWorkspaceResolver {
constructor(
private readonly cache: Cache,
private readonly event: EventEmitter,
private readonly event: EventBus,
private readonly url: URLHelper,
private readonly prisma: PrismaClient,
private readonly permissions: PermissionService,
@@ -344,7 +343,7 @@ export class TeamWorkspaceResolver {
@OnEvent('workspace.members.reviewRequested')
async onReviewRequested({
inviteId,
}: EventPayload<'workspace.members.reviewRequested'>) {
}: Events['workspace.members.reviewRequested']) {
// send review request mail to owner and admin
await this.workspaceService.sendReviewRequestedEmail(inviteId);
}
@@ -352,7 +351,7 @@ export class TeamWorkspaceResolver {
@OnEvent('workspace.members.requestApproved')
async onApproveRequest({
inviteId,
}: EventPayload<'workspace.members.requestApproved'>) {
}: Events['workspace.members.requestApproved']) {
// send approve mail
await this.workspaceService.sendReviewApproveEmail(inviteId);
}
@@ -361,7 +360,7 @@ export class TeamWorkspaceResolver {
async onDeclineRequest({
userId,
workspaceId,
}: EventPayload<'workspace.members.requestDeclined'>) {
}: Events['workspace.members.requestDeclined']) {
const user = await this.models.user.getPublicUser(userId);
// send decline mail
await this.workspaceService.sendReviewDeclinedEmail(
@@ -375,7 +374,7 @@ export class TeamWorkspaceResolver {
userId,
workspaceId,
permission,
}: EventPayload<'workspace.members.roleChanged'>) {
}: Events['workspace.members.roleChanged']) {
// send role changed mail
await this.workspaceService.sendRoleChangedEmail(userId, {
id: workspaceId,
@@ -388,7 +387,7 @@ export class TeamWorkspaceResolver {
workspaceId,
from,
to,
}: EventPayload<'workspace.members.ownershipTransferred'>) {
}: Events['workspace.members.ownershipTransferred']) {
// send ownership transferred mail
const fromUser = await this.models.user.getPublicUser(from);
const toUser = await this.models.user.getPublicUser(to);

View File

@@ -18,7 +18,7 @@ import {
AlreadyInSpace,
Cache,
DocNotFound,
EventEmitter,
EventBus,
InternalServerError,
MemberQuotaExceeded,
QueryTooLong,
@@ -83,7 +83,7 @@ export class WorkspaceResolver {
private readonly permissions: PermissionService,
private readonly quota: QuotaManagementService,
private readonly models: Models,
private readonly event: EventEmitter,
private readonly event: EventBus,
private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService,
private readonly workspaceStorage: PgWorkspaceDocStorageAdapter
@@ -389,7 +389,7 @@ export class WorkspaceResolver {
});
await this.workspaceStorage.deleteSpace(id);
this.event.emit('workspace.deleted', id);
this.event.emit('workspace.deleted', { id });
return true;
}