feat(server): realtime notification & task status (#14934)

#### PR Dependency Tree


* **PR #14934** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Full realtime platform added: live notifications, comments, embedding
progress, and transcription task updates via realtime subscriptions.

* **Chores**
* Frontend switched from polling/GraphQL queries to realtime channels;
legacy query fields marked deprecated and client libs updated to use
realtime APIs.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14934)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #14934** 👈
  * **PR #14936**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-05-10 23:21:50 +08:00
committed by GitHub
parent 417d31cabe
commit 8cf00738c2
70 changed files with 2378 additions and 283 deletions
@@ -273,7 +273,7 @@ e2e('should mark notification as read', async t => {
const count = await app.gql({
query: notificationCountQuery,
});
t.is(count.currentUser!.notifications.totalCount, 0);
t.is(count.currentUser!.notificationCount, 0);
// read again should work
for (const notification of notifications) {
@@ -42,6 +42,7 @@ import { NotificationModule } from './core/notification';
import { PermissionModule } from './core/permission';
import { QueueDashboardModule } from './core/queue-dashboard';
import { QuotaModule } from './core/quota';
import { RealtimeModule } from './core/realtime';
import { SelfhostModule } from './core/selfhost';
import { StaticFileModule } from './core/static-files';
import { StorageModule } from './core/storage';
@@ -117,6 +118,7 @@ export const FunctionalityModules = [
ErrorModule,
WebSocketModule,
JobModule.forRoot(),
RealtimeModule,
ModelsModule,
ScheduleModule.forRoot(),
MonitorModule,
@@ -3,12 +3,13 @@ import { Module } from '@nestjs/common';
import { ServerConfigModule } from '../config';
import { PermissionModule } from '../permission';
import { StorageModule } from '../storage';
import { CommentRealtimeProvider } from './realtime';
import { CommentResolver } from './resolver';
import { CommentService } from './service';
@Module({
imports: [PermissionModule, StorageModule, ServerConfigModule],
providers: [CommentResolver, CommentService],
providers: [CommentResolver, CommentService, CommentRealtimeProvider],
exports: [CommentService],
})
export class CommentModule {}
@@ -0,0 +1,99 @@
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { z } from 'zod';
import { decodeWithJson, encodeWithJson } from '../../base/graphql';
import { AccessController } from '../permission';
import type { RealtimePublisher, RealtimeRegistry } from '../realtime';
import type { CommentCursor } from './resolver';
import { CommentService } from './service';
export function commentRoom(workspaceId: string, docId: string) {
return `workspace:${workspaceId}:doc:${docId}:comment`;
}
@Injectable()
export class CommentRealtimeProvider implements OnModuleInit {
constructor(
private readonly service: CommentService,
private readonly ac: AccessController,
@Optional() private readonly registry?: RealtimeRegistry
) {}
onModuleInit() {
const input = z.object({
workspaceId: z.string(),
docId: z.string(),
after: z.string().optional(),
first: z.number().optional(),
});
this.registry?.registerRequest({
name: 'comment.changes.get',
input,
handle: async (user, payload) => {
await this.assertRead(user.id, payload.workspaceId, payload.docId);
const cursor: CommentCursor = decodeWithJson(payload.after) ?? {};
const changes = await this.service.listCommentChanges(
payload.workspaceId,
payload.docId,
{
commentUpdatedAt: cursor.commentUpdatedAt,
replyUpdatedAt: cursor.replyUpdatedAt,
take: payload.first,
}
);
const endCursor = cursor;
for (const change of changes) {
if (change.commentId) {
endCursor.replyUpdatedAt = change.item.updatedAt;
} else {
endCursor.commentUpdatedAt = change.item.updatedAt;
}
}
return {
changes: changes.map(change => ({
id: change.id,
action: change.action,
item: change.item,
commentId: change.commentId ?? undefined,
})) as never,
startCursor: '',
endCursor: encodeWithJson(endCursor),
hasNextPage: changes.length > 0,
};
},
});
this.registry?.registerTopic({
name: 'comment.changed',
input: z.object({
workspaceId: z.string(),
docId: z.string(),
}),
authorize: async (user, payload) => {
await this.assertRead(user.id, payload.workspaceId, payload.docId);
},
room: (_user, payload) => commentRoom(payload.workspaceId, payload.docId),
});
}
private async assertRead(userId: string, workspaceId: string, docId: string) {
await this.ac
.user(userId)
.workspace(workspaceId)
.doc(docId)
.assert('Doc.Comments.Read');
}
}
export function publishCommentChanged(
publisher: RealtimePublisher | undefined,
workspaceId: string,
docId: string
) {
publisher?.publish(
'comment.changed',
{ workspaceId, docId },
{ changed: true }
);
}
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Optional } from '@nestjs/common';
import {
Args,
Mutation,
@@ -26,9 +27,11 @@ import { Comment, DocMode, Models, Reply } from '../../models';
import { CurrentUser } from '../auth/session';
import { ServerFeature, ServerService } from '../config';
import { AccessController, DocAction } from '../permission';
import type { RealtimePublisher } from '../realtime';
import { CommentAttachmentStorage } from '../storage';
import { UserType } from '../user';
import { WorkspaceType } from '../workspaces';
import { publishCommentChanged } from './realtime';
import { CommentService } from './service';
import {
CommentCreateInput,
@@ -56,7 +59,8 @@ export class CommentResolver {
private readonly commentAttachmentStorage: CommentAttachmentStorage,
private readonly queue: JobQueue,
private readonly models: Models,
private readonly server: ServerService
private readonly server: ServerService,
@Optional() private readonly realtime?: RealtimePublisher
) {
// enable comment feature by default
this.server.enableFeature(ServerFeature.Comment);
@@ -81,6 +85,7 @@ export class CommentResolver {
input.docMode,
input.mentions
);
publishCommentChanged(this.realtime, comment.workspaceId, comment.docId);
return {
...comment,
@@ -108,6 +113,7 @@ export class CommentResolver {
await this.assertPermission(me, comment, 'Doc.Comments.Update');
await this.service.updateComment(input);
publishCommentChanged(this.realtime, comment.workspaceId, comment.docId);
return true;
}
@@ -126,6 +132,7 @@ export class CommentResolver {
await this.assertPermission(me, comment, 'Doc.Comments.Resolve');
await this.service.resolveComment(input);
publishCommentChanged(this.realtime, comment.workspaceId, comment.docId);
return true;
}
@@ -141,6 +148,7 @@ export class CommentResolver {
await this.assertPermission(me, comment, 'Doc.Comments.Delete');
await this.service.deleteComment(id);
publishCommentChanged(this.realtime, comment.workspaceId, comment.docId);
return true;
}
@@ -169,6 +177,7 @@ export class CommentResolver {
input.mentions,
reply
);
publishCommentChanged(this.realtime, comment.workspaceId, comment.docId);
return {
...reply,
@@ -195,6 +204,7 @@ export class CommentResolver {
await this.assertPermission(me, reply, 'Doc.Comments.Update');
await this.service.updateReply(input);
publishCommentChanged(this.realtime, reply.workspaceId, reply.docId);
return true;
}
@@ -210,6 +220,7 @@ export class CommentResolver {
await this.assertPermission(me, reply, 'Doc.Comments.Delete');
await this.service.deleteReply(id);
publishCommentChanged(this.realtime, reply.workspaceId, reply.docId);
return true;
}
@@ -294,6 +305,7 @@ export class CommentResolver {
})
pagination: PaginationInput
): Promise<PaginatedCommentChangeObjectType> {
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
await this.assertPermission(
me,
{
@@ -5,6 +5,7 @@ import { MailModule } from '../mail';
import { PermissionModule } from '../permission';
import { StorageModule } from '../storage';
import { NotificationJob } from './job';
import { NotificationRealtimeProvider } from './realtime';
import { NotificationResolver, UserNotificationResolver } from './resolver';
import { NotificationService } from './service';
@@ -15,6 +16,7 @@ import { NotificationService } from './service';
NotificationResolver,
NotificationService,
NotificationJob,
NotificationRealtimeProvider,
],
exports: [NotificationService],
})
@@ -0,0 +1,3 @@
export function notificationCountRoom(userId: string) {
return `user:${userId}:notification`;
}
@@ -0,0 +1,36 @@
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { z } from 'zod';
import type { RealtimeRegistry } from '../realtime';
import { notificationCountRoom } from './realtime-room';
import { NotificationService } from './service';
@Injectable()
export class NotificationRealtimeProvider implements OnModuleInit {
constructor(
private readonly service: NotificationService,
@Optional() private readonly registry?: RealtimeRegistry
) {}
onModuleInit() {
this.registry?.registerRequest({
name: 'notification.count.get',
input: z.object({}).strict(),
handle: async user => ({
count: await this.service.countByUserId(user.id),
}),
});
this.registry?.registerTopic({
name: 'notification.count.changed',
input: z.object({}).strict(),
authorize: async () => {},
room: user => {
if (!user) {
throw new Error('User is required for notification count room');
}
return notificationCountRoom(user.id);
},
});
}
}
@@ -47,8 +47,11 @@ export class UserNotificationResolver {
@ResolveField(() => Int, {
description: 'Get user notification count',
deprecationReason:
'Use realtime subscription "notification.count.changed" instead.',
})
async notificationCount(@CurrentUser() me: UserType): Promise<number> {
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
return await this.service.countByUserId(me.id);
}
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { NotificationNotFound, PaginationInput, URLHelper } from '../../base';
@@ -17,11 +17,13 @@ import {
} from '../../models';
import { DocReader } from '../doc';
import { Mailer } from '../mail';
import type { RealtimePublisher } from '../realtime';
import { generateDocPath } from '../utils/doc';
import {
generateWorkspaceSettingsPath,
WorkspaceSettingsTab,
} from '../utils/workspace';
import { notificationCountRoom } from './realtime-room';
@Injectable()
export class NotificationService {
@@ -31,11 +33,22 @@ export class NotificationService {
private readonly models: Models,
private readonly docReader: DocReader,
private readonly mailer: Mailer,
private readonly url: URLHelper
private readonly url: URLHelper,
@Optional() private readonly realtime?: RealtimePublisher
) {}
async cleanExpiredNotifications() {
return await this.models.notification.cleanExpiredNotifications();
const userIds =
await this.models.notification.findExpiredNotificationUserIds();
const count = await this.models.notification.cleanExpiredNotifications();
if (count > 0) {
await Promise.all(
userIds.map(userId =>
this.publishCountChanged(userId, 'expired-cleanup')
)
);
}
return count;
}
async createComment(input: CommentNotificationCreate, isMention?: boolean) {
@@ -43,6 +56,7 @@ export class NotificationService {
? await this.models.notification.createCommentMention(input)
: await this.models.notification.createComment(input);
await this.sendCommentEmail(input, isMention);
await this.publishCountChanged(input.userId, 'created');
return notification;
}
@@ -93,6 +107,7 @@ export class NotificationService {
async createMention(input: MentionNotificationCreate) {
const notification = await this.models.notification.createMention(input);
await this.sendMentionEmail(input);
await this.publishCountChanged(input.userId, 'created');
return notification;
}
@@ -147,6 +162,7 @@ export class NotificationService {
NotificationType.Invitation
);
await this.sendInvitationEmail(input);
await this.publishCountChanged(input.userId, 'created');
return notification;
}
@@ -198,6 +214,7 @@ export class NotificationService {
NotificationType.InvitationAccepted
);
await this.sendInvitationAcceptedEmail(input);
await this.publishCountChanged(input.userId, 'created');
return notification;
}
@@ -240,18 +257,22 @@ export class NotificationService {
async createInvitationBlocked(input: InvitationNotificationCreate) {
await this.ensureWorkspaceContentExists(input.body.workspaceId);
return await this.models.notification.createInvitation(
const notification = await this.models.notification.createInvitation(
input,
NotificationType.InvitationBlocked
);
await this.publishCountChanged(input.userId, 'created');
return notification;
}
async createInvitationRejected(input: InvitationNotificationCreate) {
await this.ensureWorkspaceContentExists(input.body.workspaceId);
return await this.models.notification.createInvitation(
const notification = await this.models.notification.createInvitation(
input,
NotificationType.InvitationRejected
);
await this.publishCountChanged(input.userId, 'created');
return notification;
}
async createInvitationReviewRequest(input: InvitationNotificationCreate) {
@@ -267,6 +288,7 @@ export class NotificationService {
NotificationType.InvitationReviewRequest
);
await this.sendInvitationReviewRequestEmail(input);
await this.publishCountChanged(input.userId, 'created');
return notification;
}
@@ -315,6 +337,7 @@ export class NotificationService {
NotificationType.InvitationReviewApproved
);
await this.sendInvitationReviewApprovedEmail(input);
await this.publishCountChanged(input.userId, 'created');
return notification;
}
@@ -354,6 +377,7 @@ export class NotificationService {
const notification =
await this.models.notification.createInvitationReviewDeclined(input);
await this.sendInvitationReviewDeclinedEmail(input);
await this.publishCountChanged(input.userId, 'created');
return notification;
}
@@ -397,10 +421,12 @@ export class NotificationService {
}
throw err;
}
await this.publishCountChanged(userId, 'read');
}
async markAllAsRead(userId: string) {
await this.models.notification.markAllAsRead(userId);
await this.publishCountChanged(userId, 'read-all');
}
/**
@@ -463,6 +489,26 @@ export class NotificationService {
return await this.models.notification.countByUserId(userId);
}
private async publishCountChanged(
userId: string,
reason: 'created' | 'read' | 'read-all' | 'expired-cleanup'
) {
if (!this.realtime) return;
try {
this.realtime.publish(
'notification.count.changed',
{},
{ count: await this.countByUserId(userId), reason },
{ room: notificationCountRoom(userId) }
);
} catch (error) {
this.logger.error(
`Failed to publish notification count for user ${userId}`,
error
);
}
}
private formatWorkspaceInfo(workspace: Workspace) {
return {
id: workspace.id,
@@ -0,0 +1,170 @@
import test from 'ava';
import { z } from 'zod';
import type { CurrentUser } from '../../auth';
import { RealtimeGateway } from '../gateway';
import type { RealtimePublisher } from '../publisher';
import { RealtimeRegistry } from '../registry';
import { stableStringify } from '../stable-stringify';
const user: CurrentUser = {
id: 'u1',
email: 'u1@affine.pro',
name: 'User',
avatarUrl: null,
disabled: false,
hasPassword: true,
emailVerified: true,
};
function createGateway(registry: RealtimeRegistry) {
return new RealtimeGateway(registry, {
attachServer() {},
publishLocal() {},
} as unknown as RealtimePublisher);
}
test('registry rejects duplicate request and topic handlers', t => {
const registry = new RealtimeRegistry();
const request = {
name: 'notification.count.get' as const,
input: z.object({}).strict(),
handle: async () => ({ count: 0 }),
};
const topic = {
name: 'notification.count.changed' as const,
input: z.object({}).strict(),
authorize: async () => {},
room: () => 'room',
};
registry.registerRequest(request);
registry.registerTopic(topic);
t.throws(() => registry.registerRequest(request), {
message: /already registered/,
});
t.throws(() => registry.registerTopic(topic), {
message: /already registered/,
});
});
test('gateway handles registered request with version gate', async t => {
const registry = new RealtimeRegistry();
registry.registerRequest({
name: 'notification.count.get',
input: z.object({}).strict(),
handle: async currentUser => ({ count: currentUser.id === 'u1' ? 1 : 0 }),
});
const gateway = createGateway(registry);
t.deepEqual(
await gateway.onRequest(user, {
op: 'notification.count.get',
input: {},
clientVersion: '0.26.0',
}),
{ data: { count: 1 } }
);
t.like(
await gateway.onRequest(user, {
op: 'notification.count.get',
input: {},
clientVersion: '0.25.0',
}),
{ error: { code: 'UNSUPPORTED_CLIENT_VERSION' } }
);
});
test('gateway authorizes subscription and joins room', async t => {
const registry = new RealtimeRegistry();
registry.registerTopic({
name: 'comment.changed',
input: z.object({ workspaceId: z.string(), docId: z.string() }),
authorize: async (_currentUser, input) => {
if (input.workspaceId !== 'space') {
throw new Error('denied');
}
},
room: (_currentUser, input) => `workspace:${input.workspaceId}`,
});
const gateway = createGateway(registry);
const joined: string[] = [];
const client = {
id: 'socket-1',
join: async (room: string) => {
joined.push(room);
},
leave: async (room: string) => {
joined.splice(joined.indexOf(room), 1);
},
};
const result = await gateway.onSubscribe(user, client as never, {
topic: 'comment.changed',
input: { workspaceId: 'space', docId: 'doc' },
clientVersion: '0.26.0',
});
t.deepEqual(joined, ['workspace:space']);
t.deepEqual(result, {
data: {
subscriptionId: `socket-1:comment.changed:${stableStringify({
workspaceId: 'space',
docId: 'doc',
})}`,
},
});
t.like(
await gateway.onSubscribe(user, client as never, {
topic: 'comment.changed',
input: { workspaceId: 'other', docId: 'doc' },
clientVersion: '0.26.0',
}),
{ error: { code: 'INTERNAL_SERVER_ERROR' } }
);
});
test('stableStringify is deterministic for subscription input keys', t => {
t.is(
stableStringify({ docId: 'doc', workspaceId: 'space' }),
stableStringify({ workspaceId: 'space', docId: 'doc' })
);
});
test('stableStringify follows JSON semantics for subscription input keys', t => {
t.is(stableStringify({ after: undefined }), stableStringify({}));
t.is(stableStringify([undefined]), '[null]');
t.is(
stableStringify(new Date('2026-01-02T03:04:05.000Z')),
'"2026-01-02T03:04:05.000Z"'
);
});
test('gateway removes subscriptions on socket disconnect', async t => {
const registry = new RealtimeRegistry();
registry.registerTopic({
name: 'notification.count.changed',
input: z.object({}).strict(),
authorize: async () => {},
room: () => 'user:u1:notification-count',
});
const gateway = createGateway(registry);
const client = {
id: 'socket-1',
join: async () => {},
leave: async () => {},
};
await gateway.onSubscribe(user, client as never, {
topic: 'notification.count.changed',
input: {},
clientVersion: '0.26.0',
});
t.is((gateway as any).subscriptions.size, 1);
gateway.handleDisconnect(client as never);
t.is((gateway as any).subscriptions.size, 0);
});
@@ -0,0 +1,136 @@
import type {
RealtimeRequestEnvelope,
RealtimeSubscribeEnvelope,
RealtimeUnsubscribeEnvelope,
} from '@affine/realtime';
import { applyDecorators, Logger, UseInterceptors } from '@nestjs/common';
import {
ConnectedSocket,
MessageBody,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage as RawSubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { ClsInterceptor } from 'nestjs-cls';
import semver from 'semver';
import type { Server, Socket } from 'socket.io';
import {
GatewayErrorWrapper,
OnEvent,
UnsupportedClientVersion,
} from '../../base';
import { CurrentUser } from '../auth';
import { RealtimePublisher } from './publisher';
import { RealtimeRegistry } from './registry';
import { stableStringify } from './stable-stringify';
import type { RealtimePublishPayload } from './types';
const SubscribeMessage = (event: string) =>
applyDecorators(GatewayErrorWrapper(event), RawSubscribeMessage(event));
const MIN_REALTIME_CLIENT_VERSION = new semver.Range('>=0.26.0-0', {
includePrerelease: true,
});
@WebSocketGateway()
@UseInterceptors(ClsInterceptor)
export class RealtimeGateway implements OnGatewayInit, OnGatewayDisconnect {
private readonly logger = new Logger(RealtimeGateway.name);
private readonly subscriptions = new Map<
string,
{ socketId: string; room: string }
>();
@WebSocketServer()
private readonly server!: Server;
constructor(
private readonly registry: RealtimeRegistry,
private readonly publisher: RealtimePublisher
) {}
afterInit(_server: Server) {
this.publisher.attachServer(this.server);
}
handleDisconnect(client: Socket) {
for (const [subscriptionId, subscription] of this.subscriptions) {
if (subscription.socketId === client.id) {
this.subscriptions.delete(subscriptionId);
}
}
}
@SubscribeMessage('realtime:request')
async onRequest(
@CurrentUser() user: CurrentUser,
@MessageBody() envelope: RealtimeRequestEnvelope
) {
this.assertVersion(envelope.clientVersion);
const handler = this.registry.getRequest(envelope.op);
const input = handler.input.parse(envelope.input);
return { data: await handler.handle(user, input as never) };
}
@SubscribeMessage('realtime:subscribe')
async onSubscribe(
@CurrentUser() user: CurrentUser,
@ConnectedSocket() client: Socket,
@MessageBody() envelope: RealtimeSubscribeEnvelope
) {
this.assertVersion(envelope.clientVersion);
const handler = this.registry.getTopic(envelope.topic);
const input = handler.input.parse(envelope.input);
await handler.authorize(user, input as never);
const room = handler.room(user, input as never);
await client.join(room);
const subscriptionId = `${client.id}:${envelope.topic}:${stableStringify(input)}`;
this.subscriptions.set(subscriptionId, {
socketId: client.id,
room,
});
return { data: { subscriptionId } };
}
@SubscribeMessage('realtime:unsubscribe')
async onUnsubscribe(
@ConnectedSocket() client: Socket,
@MessageBody() envelope: RealtimeUnsubscribeEnvelope
) {
this.assertVersion(envelope.clientVersion);
if (!envelope.subscriptionId) {
return { data: { ok: true } };
}
const subscription = this.subscriptions.get(envelope.subscriptionId);
if (subscription?.socketId === client.id) {
await client.leave(subscription.room);
this.subscriptions.delete(envelope.subscriptionId);
}
return { data: { ok: true } };
}
@OnEvent('realtime.topic.changed', { suppressError: true })
onRealtimeTopicChanged(payload: RealtimePublishPayload) {
try {
this.publisher.publishLocal(payload);
} catch (error) {
this.logger.error('Failed to publish realtime event', error);
}
}
private assertVersion(clientVersion?: string) {
if (
!clientVersion ||
!semver.valid(clientVersion) ||
!MIN_REALTIME_CLIENT_VERSION.test(clientVersion)
) {
throw new UnsupportedClientVersion({
clientVersion: clientVersion ?? 'unset_or_invalid',
requiredVersion: '>=0.26.0',
});
}
}
}
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { RealtimeGateway } from './gateway';
import { RealtimePublisher } from './publisher';
import { RealtimeRegistry } from './registry';
@Global()
@Module({
providers: [RealtimeRegistry, RealtimePublisher, RealtimeGateway],
exports: [RealtimeRegistry, RealtimePublisher],
})
export class RealtimeModule {}
export { RealtimePublisher } from './publisher';
export { RealtimeRegistry } from './registry';
export type { RealtimeRequestHandler, RealtimeTopicHandler } from './types';
@@ -0,0 +1,55 @@
import type { RealtimeEvent, RealtimeTopicName } from '@affine/realtime';
import { Injectable, Logger } from '@nestjs/common';
import type { Server } from 'socket.io';
import { EventBus } from '../../base';
import { RealtimeRegistry } from './registry';
import { stableStringify } from './stable-stringify';
import type { RealtimePublishPayload } from './types';
@Injectable()
export class RealtimePublisher {
private readonly logger = new Logger(RealtimePublisher.name);
private server?: Server;
constructor(
private readonly registry: RealtimeRegistry,
private readonly event: EventBus
) {}
attachServer(server: Server) {
this.server = server;
}
publish<Topic extends RealtimeTopicName>(
topic: Topic,
input: RealtimePublishPayload<Topic>['input'],
event: RealtimePublishPayload<Topic>['event'],
options?: { room?: string }
) {
const payload = {
topic,
input,
event,
room: options?.room,
} as RealtimePublishPayload<Topic>;
try {
this.publishLocal(payload);
this.event.broadcast('realtime.topic.changed', payload);
} catch (error) {
this.logger.error(`Failed to publish realtime topic ${topic}`, error);
}
}
publishLocal(payload: RealtimePublishPayload) {
const handler = this.registry.getTopic(payload.topic);
const room = payload.room ?? handler.room(null, payload.input as never);
const envelope: RealtimeEvent = {
topic: payload.topic,
inputKey: stableStringify(payload.input),
sentAt: Date.now(),
event: payload.event as never,
};
this.server?.to(room).emit('realtime:event', envelope);
}
}
@@ -0,0 +1,60 @@
import type { RealtimeRequestName, RealtimeTopicName } from '@affine/realtime';
import { Injectable } from '@nestjs/common';
import type { RealtimeRequestHandler, RealtimeTopicHandler } from './types';
@Injectable()
export class RealtimeRegistry {
private readonly requests = new Map<
RealtimeRequestName,
RealtimeRequestHandler<RealtimeRequestName>
>();
private readonly topics = new Map<
RealtimeTopicName,
RealtimeTopicHandler<RealtimeTopicName>
>();
registerRequest<Op extends RealtimeRequestName>(
handler: RealtimeRequestHandler<Op>
) {
if (this.requests.has(handler.name)) {
throw new Error(
`Realtime request handler already registered: ${handler.name}`
);
}
this.requests.set(
handler.name,
handler as RealtimeRequestHandler<RealtimeRequestName>
);
}
registerTopic<Topic extends RealtimeTopicName>(
handler: RealtimeTopicHandler<Topic>
) {
if (this.topics.has(handler.name)) {
throw new Error(
`Realtime topic handler already registered: ${handler.name}`
);
}
this.topics.set(
handler.name,
handler as RealtimeTopicHandler<RealtimeTopicName>
);
}
getRequest(name: RealtimeRequestName) {
const handler = this.requests.get(name);
if (!handler) {
throw new Error(`Realtime request handler not found: ${name}`);
}
return handler;
}
getTopic(name: RealtimeTopicName) {
const handler = this.topics.get(name);
if (!handler) {
throw new Error(`Realtime topic handler not found: ${name}`);
}
return handler;
}
}
@@ -0,0 +1,31 @@
export function stableStringify(value: unknown): string {
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return 'null';
}
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
}
if (value instanceof Date) {
return JSON.stringify(value.toJSON());
}
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.filter(key => {
const property = record[key];
return (
property !== undefined &&
typeof property !== 'function' &&
typeof property !== 'symbol'
);
})
.sort()
.map(key => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
.join(',')}}`;
}
@@ -0,0 +1,45 @@
import type {
RealtimeRequestInputOf,
RealtimeRequestName,
RealtimeRequestOutputOf,
RealtimeTopicEventOf,
RealtimeTopicInputOf,
RealtimeTopicName,
} from '@affine/realtime';
import type { z } from 'zod';
import type { CurrentUser } from '../auth';
declare global {
interface Events {
'realtime.topic.changed': RealtimePublishPayload;
}
}
export type RealtimeRequestHandler<Op extends RealtimeRequestName> = {
name: Op;
input: z.ZodType<RealtimeRequestInputOf<Op>>;
handle(
user: CurrentUser,
input: RealtimeRequestInputOf<Op>
): Promise<RealtimeRequestOutputOf<Op>>;
};
export type RealtimeTopicHandler<Topic extends RealtimeTopicName> = {
name: Topic;
input: z.ZodType<RealtimeTopicInputOf<Topic>>;
authorize(
user: CurrentUser,
input: RealtimeTopicInputOf<Topic>
): Promise<void>;
room(user: CurrentUser | null, input: RealtimeTopicInputOf<Topic>): string;
};
export type RealtimePublishPayload<
Topic extends RealtimeTopicName = RealtimeTopicName,
> = {
topic: Topic;
input: RealtimeTopicInputOf<Topic>;
event: RealtimeTopicEventOf<Topic>;
room?: string;
};
@@ -311,6 +311,15 @@ export class NotificationModel extends BaseModel {
return row as UnionNotification;
}
async findExpiredNotificationUserIds() {
const rows = await this.db.notification.findMany({
distinct: ['userId'],
select: { userId: true },
where: { createdAt: { lte: new Date(Date.now() - ONE_YEAR) } },
});
return rows.map(row => row.userId);
}
async cleanExpiredNotifications() {
const { count } = await this.db.notification.deleteMany({
// delete notifications that are older than one year
@@ -1,2 +1,3 @@
export { CopilotEmbeddingRealtimeProvider } from './realtime';
export { CopilotContextResolver, CopilotContextRootResolver } from './resolver';
export { CopilotContextService } from './service';
@@ -0,0 +1,105 @@
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { z } from 'zod';
import { OnEvent } from '../../../base';
import { AccessController } from '../../../core/permission';
import type {
RealtimePublisher,
RealtimeRegistry,
} from '../../../core/realtime';
import { Models } from '../../../models';
import { CopilotContextService } from './service';
export function workspaceEmbeddingRoom(workspaceId: string) {
return `workspace:${workspaceId}:embedding-progress`;
}
@Injectable()
export class CopilotEmbeddingRealtimeProvider implements OnModuleInit {
constructor(
private readonly ac: AccessController,
private readonly models: Models,
private readonly context: CopilotContextService,
@Optional() private readonly registry?: RealtimeRegistry,
@Optional() private readonly publisher?: RealtimePublisher
) {}
onModuleInit() {
const input = z.object({ workspaceId: z.string() });
this.registry?.registerRequest({
name: 'workspace.embedding.progress.get',
input,
handle: async (user, payload) => {
await this.assertCopilot(user.id, payload.workspaceId);
if (!this.context.canEmbedding) {
return { total: 0, embedded: 0 };
}
return await this.models.copilotWorkspace.getEmbeddingStatus(
payload.workspaceId
);
},
});
this.registry?.registerTopic({
name: 'workspace.embedding.progress.changed',
input,
authorize: async (user, payload) => {
await this.assertCopilot(user.id, payload.workspaceId);
},
room: (_user, payload) => workspaceEmbeddingRoom(payload.workspaceId),
});
}
@OnEvent('workspace.doc.embed.finished', { suppressError: true })
async onDocEmbedFinished(payload: Events['workspace.doc.embed.finished']) {
await this.publishContext(payload.contextId, 'finished');
}
@OnEvent('workspace.doc.embed.failed', { suppressError: true })
async onDocEmbedFailed(payload: Events['workspace.doc.embed.failed']) {
await this.publishContext(payload.contextId, 'failed');
}
@OnEvent('workspace.file.embed.finished', { suppressError: true })
async onFileEmbedFinished(payload: Events['workspace.file.embed.finished']) {
await this.publishContext(payload.contextId, 'finished');
}
@OnEvent('workspace.file.embed.failed', { suppressError: true })
async onFileEmbedFailed(payload: Events['workspace.file.embed.failed']) {
await this.publishContext(payload.contextId, 'failed');
}
@OnEvent('workspace.blob.embed.finished', { suppressError: true })
async onBlobEmbedFinished(payload: Events['workspace.blob.embed.finished']) {
await this.publishContext(payload.contextId, 'finished');
}
@OnEvent('workspace.blob.embed.failed', { suppressError: true })
async onBlobEmbedFailed(payload: Events['workspace.blob.embed.failed']) {
await this.publishContext(payload.contextId, 'failed');
}
private async publishContext(
contextId: string,
reason: 'finished' | 'failed'
) {
if (!this.publisher) return;
const context = await this.context.get(contextId);
this.publisher.publish(
'workspace.embedding.progress.changed',
{ workspaceId: context.workspaceId },
{ reason },
{ room: workspaceEmbeddingRoom(context.workspaceId) }
);
}
private async assertCopilot(userId: string, workspaceId: string) {
await this.ac
.user(userId)
.workspace(workspaceId)
.allowLocal()
.assert('Workspace.Copilot');
}
}
@@ -412,12 +412,15 @@ export class CopilotContextRootResolver {
@Throttle('strict')
@Query(() => ContextWorkspaceEmbeddingStatus, {
description: 'query workspace embedding status',
deprecationReason:
'Use realtime subscription "workspace.embedding.progress.changed" instead.',
})
@CallMetric('ai', 'context_query_workspace_embedding_status')
async queryWorkspaceEmbeddingStatus(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
): Promise<ContextWorkspaceEmbeddingStatus> {
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
await this.ac
.user(user.id)
.workspace(workspaceId)
@@ -13,6 +13,7 @@ import {
CopilotContextResolver,
CopilotContextRootResolver,
CopilotContextService,
CopilotEmbeddingRealtimeProvider,
} from './context';
import { ConversationInboxService } from './conversation/inbox';
import { ConversationPolicy } from './conversation/policy';
@@ -55,6 +56,7 @@ import { CopilotStorage } from './storage';
import {
CopilotTranscriptionResolver,
CopilotTranscriptionService,
CopilotTranscriptRealtimeProvider,
} from './transcript';
import {
CopilotWorkspaceEmbeddingConfigResolver,
@@ -106,11 +108,15 @@ export const COPILOT_RUNTIME_PROVIDERS = [
TurnPersistence,
];
export const COPILOT_CONTEXT_PROVIDERS = [CopilotContextResolver];
export const COPILOT_CONTEXT_PROVIDERS = [
CopilotContextResolver,
CopilotEmbeddingRealtimeProvider,
];
export const COPILOT_TRANSCRIPT_PROVIDERS = [
CopilotTranscriptionService,
CopilotTranscriptionResolver,
CopilotTranscriptRealtimeProvider,
];
export const COPILOT_WORKSPACE_PROVIDERS = [
@@ -1,2 +1,3 @@
export { CopilotTranscriptRealtimeProvider } from './realtime';
export { CopilotTranscriptionResolver } from './resolver';
export { CopilotTranscriptionService } from './service';
@@ -0,0 +1,69 @@
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { z } from 'zod';
import { CopilotTranscriptionJobNotFound } from '../../../base';
import { AccessController } from '../../../core/permission';
import type { RealtimeRegistry } from '../../../core/realtime';
import { CopilotTranscriptionService, transcriptTaskRoom } from './service';
@Injectable()
export class CopilotTranscriptRealtimeProvider implements OnModuleInit {
constructor(
private readonly ac: AccessController,
private readonly transcript: CopilotTranscriptionService,
@Optional() private readonly registry?: RealtimeRegistry
) {}
onModuleInit() {
this.registry?.registerRequest({
name: 'copilot.transcript.task.get',
input: z
.object({
workspaceId: z.string(),
blobId: z.string().optional(),
taskId: z.string().optional(),
})
.refine(input => input.blobId || input.taskId),
handle: async (user, input) => {
await this.assertCopilot(user.id, input.workspaceId);
return {
task: await this.transcript.queryTask(
user.id,
input.workspaceId,
input.taskId,
input.blobId
),
};
},
});
this.registry?.registerTopic({
name: 'copilot.transcript.task.changed',
input: z.object({
workspaceId: z.string(),
taskId: z.string(),
}),
authorize: async (user, input) => {
await this.assertCopilot(user.id, input.workspaceId);
const task = await this.transcript.queryTask(
user.id,
input.workspaceId,
input.taskId
);
if (!task) {
throw new CopilotTranscriptionJobNotFound();
}
},
room: (_user, input) =>
transcriptTaskRoom(input.workspaceId, input.taskId),
});
}
private async assertCopilot(userId: string, workspaceId: string) {
await this.ac
.user(userId)
.workspace(workspaceId)
.allowLocal()
.assert('Workspace.Copilot');
}
}
@@ -416,6 +416,8 @@ export class CopilotTranscriptionResolver {
@ResolveField(() => TranscriptionResultType, {
nullable: true,
deprecationReason:
'Use realtime subscription "copilot.transcript.task.changed" instead.',
})
async transcriptTask(
@Parent() copilot: CopilotType,
@@ -425,6 +427,7 @@ export class CopilotTranscriptionResolver {
@Args('blobId', { nullable: true })
blobId?: string
): Promise<TranscriptionResultType | null> {
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
if (!copilot.workspaceId) return null;
if (!taskId && !blobId) return null;
@@ -9,6 +9,7 @@ import {
OnJob,
sniffMime,
} from '../../../base';
import type { RealtimePublisher } from '../../../core/realtime';
import { Models } from '../../../models';
import { CopilotAccessPolicy } from '../access';
import { PromptService } from '../prompt';
@@ -32,6 +33,10 @@ const TRANSCRIPT_ACTION_ID = 'transcript.audio.gemini';
const TRANSCRIPT_ACTION_VERSION = 'v1';
const TRANSCRIPT_STRATEGY = 'gemini';
export function transcriptTaskRoom(workspaceId: string, taskId: string) {
return `copilot:transcript:${workspaceId}:${taskId}`;
}
export type TranscriptionJob = {
id: string;
status: AiJobStatus;
@@ -62,7 +67,8 @@ export class CopilotTranscriptionService {
private readonly tasks: TaskPolicy,
private readonly prompts: PromptService,
private readonly actionBridge: ActionRuntimeBridge,
@Optional() private readonly access?: CopilotAccessPolicy
@Optional() private readonly access?: CopilotAccessPolicy,
@Optional() private readonly realtime?: RealtimePublisher
) {}
private parseTaskPayload(payload: unknown): TranscriptionPayloadV2 {
@@ -252,6 +258,7 @@ export class CopilotTranscriptionService {
modelId: model,
});
await this.models.copilotTranscriptTask.markRunning(task.id);
this.publishTaskChanged(workspaceId, task.id, AiJobStatus.running);
return { id: task.id, status: AiJobStatus.running, infos };
}
@@ -294,6 +301,7 @@ export class CopilotTranscriptionService {
retryOf: task.actionRunId ?? undefined,
});
await this.models.copilotTranscriptTask.markRunning(taskId);
this.publishTaskChanged(workspaceId, taskId, AiJobStatus.running);
return {
id: taskId,
status: AiJobStatus.running,
@@ -389,6 +397,11 @@ export class CopilotTranscriptionService {
},
onRunCreated: async ({ runId }) => {
await this.models.copilotTranscriptTask.markRunning(taskId, runId);
this.publishTaskChanged(
task.workspaceId,
taskId,
AiJobStatus.running
);
},
prepareStructuredRoutes: {
stepId: 'transcribe',
@@ -425,6 +438,7 @@ export class CopilotTranscriptionService {
protectedResult: parsedResult,
errorCode: null,
});
this.publishTaskChanged(task.workspaceId, taskId, AiJobStatus.finished);
} catch (error) {
await this.models.copilotTranscriptTask.complete(taskId, {
status: 'failed',
@@ -434,7 +448,27 @@ export class CopilotTranscriptionService {
errorCode:
error instanceof Error ? error.message : 'transcript_task_failed',
});
this.publishTaskChanged(
task.workspaceId,
taskId,
AiJobStatus.failed,
error instanceof Error ? error.message : 'transcript_task_failed'
);
throw error;
}
}
private publishTaskChanged(
workspaceId: string,
taskId: string,
status: AiJobStatus,
error?: string
) {
this.realtime?.publish(
'copilot.transcript.task.changed',
{ workspaceId, taskId },
{ taskId, status, error },
{ room: transcriptTaskRoom(workspaceId, taskId) }
);
}
}
+3 -3
View File
@@ -519,7 +519,7 @@ type Copilot {
"""Get the session list in the workspace"""
sessions(docId: String, options: QueryChatSessionsInput): [CopilotSessionType!]! @deprecated(reason: "use `chats` instead")
transcriptTask(blobId: String, taskId: String): TranscriptionResultType
transcriptTask(blobId: String, taskId: String): TranscriptionResultType @deprecated(reason: "Use realtime subscription \"copilot.transcript.task.changed\" instead.")
workspaceId: ID
}
@@ -1975,7 +1975,7 @@ type Query {
publicUserById(id: String!): PublicUserType
"""query workspace embedding status"""
queryWorkspaceEmbeddingStatus(workspaceId: String!): ContextWorkspaceEmbeddingStatus!
queryWorkspaceEmbeddingStatus(workspaceId: String!): ContextWorkspaceEmbeddingStatus! @deprecated(reason: "Use realtime subscription \"workspace.embedding.progress.changed\" instead.")
revealedAccessTokens: [RevealedAccessToken!]! @deprecated(reason: "use currentUser.revealedAccessTokens")
"""server config"""
@@ -2668,7 +2668,7 @@ type UserType {
name: String!
"""Get user notification count"""
notificationCount: Int!
notificationCount: Int! @deprecated(reason: "Use realtime subscription \"notification.count.changed\" instead.")
"""Get current user notifications"""
notifications(pagination: PaginationInput!): PaginatedNotificationObjectType!