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
@@ -1,3 +1,9 @@
import {
type RealtimeEvent,
type RealtimeRequestEnvelope,
type RealtimeSubscribeEnvelope,
type RealtimeUnsubscribeEnvelope,
} from '@affine/realtime';
import {
Manager as SocketIOManager,
type Socket as SocketIO,
@@ -52,6 +58,8 @@ interface ServerEvents {
docId: string;
awarenessUpdate: string;
};
'realtime:event': RealtimeEvent;
}
interface ClientEvents {
@@ -116,6 +124,10 @@ interface ClientEvents {
'space:delete-doc': { spaceType: string; spaceId: string; docId: string };
'telemetry:batch': [TelemetryBatch, TelemetryAck];
'realtime:request': [RealtimeRequestEnvelope, unknown];
'realtime:subscribe': [RealtimeSubscribeEnvelope, { subscriptionId: string }];
'realtime:unsubscribe': [RealtimeUnsubscribeEnvelope, { ok: true }];
}
export type ServerEventsMap = {
@@ -227,10 +239,11 @@ class SocketManager {
const SOCKET_MANAGER_CACHE = new Map<string, SocketManager>();
function getSocketManager(endpoint: string, isSelfHosted: boolean) {
let manager = SOCKET_MANAGER_CACHE.get(endpoint);
const key = `${endpoint}:${isSelfHosted ? 'selfhosted' : 'cloud'}`;
let manager = SOCKET_MANAGER_CACHE.get(key);
if (!manager) {
manager = new SocketManager(endpoint, isSelfHosted);
SOCKET_MANAGER_CACHE.set(endpoint, manager);
SOCKET_MANAGER_CACHE.set(key, manager);
}
return manager;
}
@@ -0,0 +1,296 @@
import type { RealtimeEvent } from '@affine/realtime';
import { beforeEach, expect, test, vi } from 'vitest';
import { RealtimeManager, stableStringify } from '../manager';
type Handler = (payload?: unknown) => void;
class FakeSocket {
readonly handlers = new Map<string, Handler>();
readonly emitted: Array<{ event: string; payload: unknown }> = [];
connected = true;
disconnected = false;
nextRequestAck: unknown = { data: { count: 1 } };
subscribeAcks: unknown[] = [];
nextSubscriptionId = 0;
on(event: string, handler: Handler) {
this.handlers.set(event, handler);
}
off(event: string) {
this.handlers.delete(event);
}
async emitWithAck(event: string, payload: unknown) {
this.emitted.push({ event, payload });
if (event === 'realtime:subscribe') {
const ack = this.subscribeAcks.shift();
if (ack) return ack;
this.nextSubscriptionId += 1;
return { data: { subscriptionId: `sub-${this.nextSubscriptionId}` } };
}
if (event === 'realtime:request') {
return this.nextRequestAck;
}
return { data: {} };
}
emit(event: string, payload?: unknown) {
this.handlers.get(event)?.(payload);
}
}
const socket = new FakeSocket();
vi.mock('../../impls/cloud/socket', () => ({
SocketConnection: class {
readonly inner = { socket };
status = 'connected';
readonly maybeConnection = { socket };
connect() {}
async waitForConnected() {}
disconnect() {
socket.disconnected = true;
}
},
}));
beforeEach(() => {
vi.stubGlobal('BUILD_CONFIG', { appVersion: 'test' });
socket.handlers.clear();
socket.emitted.length = 0;
socket.nextRequestAck = { data: { count: 1 } };
socket.subscribeAcks = [];
socket.nextSubscriptionId = 0;
socket.connected = true;
socket.disconnected = false;
});
test('stableStringify is deterministic for realtime subscription inputs', () => {
expect(stableStringify({ workspaceId: 'space', docId: 'doc' })).toBe(
stableStringify({ docId: 'doc', workspaceId: 'space' })
);
});
test('stableStringify follows JSON semantics for edge values', () => {
expect(stableStringify({ a: undefined })).toBe(stableStringify({}));
expect(stableStringify([undefined])).toBe('[null]');
expect(stableStringify(new Date('2026-01-02T03:04:05.000Z'))).toBe(
'"2026-01-02T03:04:05.000Z"'
);
});
test('request sends generic realtime request with client version', async () => {
const manager = new RealtimeManager();
manager.setContext({
endpoint: 'http://server',
isSelfHosted: false,
authenticated: true,
});
await expect(manager.request('notification.count.get', {})).resolves.toEqual({
count: 1,
});
expect(socket.emitted).toEqual([
{
event: 'realtime:request',
payload: {
op: 'notification.count.get',
input: {},
clientVersion: 'test',
},
},
]);
});
test('request rejects server ack error', async () => {
const manager = new RealtimeManager();
manager.setContext({
endpoint: 'http://server',
isSelfHosted: false,
authenticated: true,
});
socket.nextRequestAck = {
error: { name: 'Forbidden', message: 'No access' },
};
await expect(manager.request('notification.count.get', {})).rejects.toThrow(
'No access'
);
});
test('request rejects when aborted', async () => {
const manager = new RealtimeManager();
manager.setContext({
endpoint: 'http://server',
isSelfHosted: false,
authenticated: true,
});
const controller = new AbortController();
socket.nextRequestAck = new Promise(() => {});
const request = manager.request(
'notification.count.get',
{},
{ signal: controller.signal }
);
controller.abort();
await expect(request).rejects.toThrow('Realtime request aborted');
});
test('subscribe routes events by topic and stable input key', async () => {
const manager = new RealtimeManager();
manager.setContext({
endpoint: 'http://server',
isSelfHosted: false,
authenticated: true,
});
const received: unknown[] = [];
const subscription = manager
.subscribe('comment.changed', { workspaceId: 'space', docId: 'doc' })
.subscribe(event => received.push(event));
await vi.waitFor(() => expect(received).toEqual([{ type: 'ready' }]));
socket.emit('realtime:event', {
topic: 'comment.changed',
inputKey: stableStringify({ workspaceId: 'space', docId: 'other' }),
sentAt: 1,
event: { changed: true },
} satisfies RealtimeEvent);
socket.emit('realtime:event', {
topic: 'comment.changed',
inputKey: stableStringify({ workspaceId: 'space', docId: 'doc' }),
sentAt: 2,
event: { changed: true },
} satisfies RealtimeEvent);
expect(received).toEqual([{ type: 'ready' }, { changed: true }]);
subscription.unsubscribe();
});
test('unsubscribe leaves server room and clears status', async () => {
const manager = new RealtimeManager();
manager.setContext({
endpoint: 'http://server',
isSelfHosted: false,
authenticated: true,
});
const subscription = manager
.subscribe('notification.count.changed', {})
.subscribe();
await vi.waitFor(() => expect(manager.getStatus().subscriptions).toBe(1));
subscription.unsubscribe();
expect(manager.getStatus()).toMatchObject({
connected: true,
subscriptions: 0,
});
expect(socket.emitted.at(-1)).toEqual({
event: 'realtime:unsubscribe',
payload: {
subscriptionId: 'sub-1',
topic: 'notification.count.changed',
input: {},
clientVersion: 'test',
},
});
});
test('context switch disconnects socket and completes subscriptions', async () => {
const manager = new RealtimeManager();
manager.setContext({
endpoint: 'http://server',
isSelfHosted: false,
authenticated: true,
});
const completed = vi.fn();
manager
.subscribe('notification.count.changed', {})
.subscribe({ complete: completed });
await vi.waitFor(() => expect(manager.getStatus().subscriptions).toBe(1));
manager.setContext({
endpoint: 'http://other-server',
isSelfHosted: false,
authenticated: true,
});
expect(socket.disconnected).toBe(true);
expect(completed).toHaveBeenCalled();
expect(manager.getStatus()).toMatchObject({
endpoint: 'http://other-server',
connected: false,
subscriptions: 0,
});
});
test('subscribe registers server room again after reconnect', async () => {
const manager = new RealtimeManager();
manager.setContext({
endpoint: 'http://server',
isSelfHosted: false,
authenticated: true,
});
const received: unknown[] = [];
const subscription = manager
.subscribe('notification.count.changed', {})
.subscribe(event => received.push(event));
await vi.waitFor(() => expect(received).toEqual([{ type: 'ready' }]));
socket.emit('connect');
await vi.waitFor(() =>
expect(
socket.emitted.filter(item => item.event === 'realtime:subscribe')
).toHaveLength(2)
);
expect(received).toEqual([{ type: 'ready' }, { type: 'ready' }]);
subscription.unsubscribe();
});
test('failed reconnect only errors the affected subscription', async () => {
const manager = new RealtimeManager();
manager.setContext({
endpoint: 'http://server',
isSelfHosted: false,
authenticated: true,
});
const first: unknown[] = [];
const firstErrors: unknown[] = [];
const second: unknown[] = [];
const secondErrors: unknown[] = [];
const firstSubscription = manager
.subscribe('notification.count.changed', {})
.subscribe({
next: event => first.push(event),
error: error => firstErrors.push(error),
});
const secondSubscription = manager
.subscribe('comment.changed', { workspaceId: 'space', docId: 'doc' })
.subscribe({
next: event => second.push(event),
error: error => secondErrors.push(error),
});
await vi.waitFor(() => expect(manager.getStatus().subscriptions).toBe(2));
socket.subscribeAcks = [
{ data: { subscriptionId: 'resub-1' } },
{ error: { name: 'Forbidden', message: 'No access' } },
];
socket.emit('connect');
await vi.waitFor(() => expect(first).toHaveLength(2));
await vi.waitFor(() => expect(secondErrors).toHaveLength(1));
expect(firstErrors).toEqual([]);
expect(manager.getStatus().subscriptions).toBe(1);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
});
@@ -0,0 +1 @@
export { RealtimeManager, stableStringify } from './manager';
@@ -0,0 +1,336 @@
import type {
RealtimeConfigureInput,
RealtimeEvent,
RealtimeRequestInputOf,
RealtimeRequestName,
RealtimeRequestOutputOf,
RealtimeStatus,
RealtimeSubscriptionReady,
RealtimeTopicEventOf,
RealtimeTopicInputOf,
RealtimeTopicName,
} from '@affine/realtime';
import { Observable, Subject } from 'rxjs';
import { SocketConnection } from '../impls/cloud/socket';
const DEFAULT_REQUEST_TIMEOUT = 10_000;
type RealtimeContext = RealtimeConfigureInput;
function normalizeError(error: unknown) {
if (error instanceof Error) {
return { name: error.name, message: error.message };
}
return { name: 'RealtimeError', message: String(error) };
}
function rejectAck(error: { name?: string; message?: string; code?: string }) {
const err = new Error(error.message ?? 'Realtime request failed');
err.name = error.name ?? 'RealtimeError';
return err;
}
export class RealtimeManager {
private context?: RealtimeContext;
private socketConnection?: SocketConnection;
private socketKey?: string;
private lastError?: { name: string; message: string };
private readonly subscriptions = new Map<
string,
{
topic: RealtimeTopicName;
input: RealtimeTopicInputOf<RealtimeTopicName>;
inputKey: string;
subject$: Subject<RealtimeEvent | RealtimeSubscriptionReady>;
}
>();
setContext(context: RealtimeContext) {
const nextContext = { ...context };
const changed =
!this.context ||
this.context.endpoint !== nextContext.endpoint ||
this.context.isSelfHosted !== nextContext.isSelfHosted ||
this.context.authenticated !== nextContext.authenticated;
this.context = nextContext;
if (changed) {
this.resetConnection();
}
}
async request<Op extends RealtimeRequestName>(
op: Op,
input: RealtimeRequestInputOf<Op>,
options?: { timeoutMs?: number; signal?: AbortSignal }
): Promise<RealtimeRequestOutputOf<Op>> {
const socket = await this.connect();
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let abortHandler: (() => void) | undefined;
const abort = () => {
const error = new Error(`Realtime request aborted: ${op}`);
error.name = 'AbortError';
return error;
};
if (options?.signal?.aborted) {
throw abort();
}
const timeout = new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => {
const error = new Error(`Realtime request timed out: ${op}`);
error.name = 'RealtimeRequestTimeout';
reject(error);
}, timeoutMs);
timeoutId.unref?.();
});
const aborted = new Promise<never>((_resolve, reject) => {
abortHandler = () => reject(abort());
options?.signal?.addEventListener('abort', abortHandler, { once: true });
});
const ack = await Promise.race([
socket.emitWithAck('realtime:request', {
op,
input,
clientVersion: BUILD_CONFIG.appVersion,
}),
timeout,
aborted,
]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (abortHandler) {
options?.signal?.removeEventListener('abort', abortHandler);
}
});
if ('error' in ack) {
throw rejectAck(ack.error);
}
return ack.data as unknown as RealtimeRequestOutputOf<Op>;
}
subscribe<Topic extends RealtimeTopicName>(
topic: Topic,
input: RealtimeTopicInputOf<Topic>
): Observable<RealtimeTopicEventOf<Topic> | RealtimeSubscriptionReady> {
return new Observable(subscriber => {
let subscriptionId: string | undefined;
let subject$: Subject<RealtimeEvent | RealtimeSubscriptionReady>;
let closed = false;
const setup = async () => {
try {
const socket = await this.connect();
const ack = await socket.emitWithAck('realtime:subscribe', {
topic,
input,
clientVersion: BUILD_CONFIG.appVersion,
});
if ('error' in ack) {
throw rejectAck(ack.error);
}
const data = ack.data;
subscriptionId = data.subscriptionId;
if (closed) {
await socket.emitWithAck('realtime:unsubscribe', {
subscriptionId: data.subscriptionId,
topic,
input,
clientVersion: BUILD_CONFIG.appVersion,
});
return;
}
subject$ = new Subject();
this.subscriptions.set(subscriptionId, {
topic,
input,
inputKey: stableStringify(input),
subject$,
});
subscriber.next({
type: 'ready',
});
subject$.subscribe({
next: event => {
if ('type' in event) {
subscriber.next(event);
} else {
subscriber.next(event.event as RealtimeTopicEventOf<Topic>);
}
},
error: error => subscriber.error(error),
complete: () => subscriber.complete(),
});
} catch (error) {
this.lastError = normalizeError(error);
subscriber.error(error);
}
};
setup().catch(error => subscriber.error(error));
return () => {
closed = true;
if (!subscriptionId) {
return;
}
const currentSubscriptionId = subscriptionId;
this.subscriptions.delete(currentSubscriptionId);
subject$?.complete();
if (this.socketConnection?.inner?.socket.connected) {
this.socketConnection.inner.socket
.emitWithAck('realtime:unsubscribe', {
subscriptionId: currentSubscriptionId,
topic,
input,
clientVersion: BUILD_CONFIG.appVersion,
})
.catch(() => {});
}
};
});
}
getStatus(): RealtimeStatus {
return {
endpoint: this.context?.endpoint,
connected: this.socketConnection?.status === 'connected',
connecting: this.socketConnection?.status === 'connecting',
subscriptions: this.subscriptions.size,
lastError: this.lastError,
};
}
private async connect() {
if (!this.context?.endpoint || !this.context.authenticated) {
const error = new Error('Realtime is not authenticated');
error.name = 'RealtimeUnauthenticated';
throw error;
}
const key = `${this.context.endpoint}:${this.context.isSelfHosted}`;
if (!this.socketConnection || this.socketKey !== key) {
this.resetConnection();
this.socketKey = key;
this.socketConnection = new SocketConnection(
this.context.endpoint,
this.context.isSelfHosted
);
this.socketConnection.connect();
}
await this.socketConnection.waitForConnected();
this.socketConnection.inner.socket.off('realtime:event', this.handleEvent);
this.socketConnection.inner.socket.on('realtime:event', this.handleEvent);
this.socketConnection.inner.socket.off('connect', this.handleReconnect);
this.socketConnection.inner.socket.on('connect', this.handleReconnect);
return this.socketConnection.inner.socket;
}
private readonly handleEvent = (event: RealtimeEvent) => {
for (const subscription of this.subscriptions.values()) {
if (
subscription.topic === event.topic &&
subscription.inputKey === event.inputKey
) {
subscription.subject$.next(event);
}
}
};
private readonly handleReconnect = () => {
this.resubscribeAll().catch(error => {
this.lastError = normalizeError(error);
});
};
private async resubscribeAll() {
const socket = this.socketConnection?.inner.socket;
if (!socket?.connected || this.subscriptions.size === 0) {
return;
}
const subscriptions = Array.from(this.subscriptions.entries());
for (const [subscriptionId, subscription] of subscriptions) {
try {
const ack = await socket.emitWithAck('realtime:subscribe', {
topic: subscription.topic,
input: subscription.input,
clientVersion: BUILD_CONFIG.appVersion,
});
if ('error' in ack) {
throw rejectAck(ack.error);
}
this.subscriptions.delete(subscriptionId);
this.subscriptions.set(ack.data.subscriptionId, subscription);
subscription.subject$.next({
type: 'ready',
});
} catch (error) {
this.lastError = normalizeError(error);
this.subscriptions.delete(subscriptionId);
subscription.subject$.error(error);
}
}
}
private resetConnection() {
if (this.socketConnection) {
this.socketConnection.maybeConnection?.socket.off(
'realtime:event',
this.handleEvent
);
this.socketConnection.maybeConnection?.socket.off(
'connect',
this.handleReconnect
);
this.socketConnection.disconnect(true);
}
this.socketConnection = undefined;
this.socketKey = undefined;
for (const subscription of this.subscriptions.values()) {
subscription.subject$.complete();
}
this.subscriptions.clear();
}
}
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(',')}}`;
}
@@ -1,3 +1,14 @@
import type {
RealtimeConfigureInput,
RealtimeRequestInputOf,
RealtimeRequestName,
RealtimeRequestOutputOf,
RealtimeStatus,
RealtimeSubscriptionReady,
RealtimeTopicEventOf,
RealtimeTopicInputOf,
RealtimeTopicName,
} from '@affine/realtime';
import { OpClient, transfer } from '@toeverything/infra/op';
import type { Observable } from 'rxjs';
import { v4 as uuid } from 'uuid';
@@ -38,6 +49,30 @@ import type { StoreInitOptions, WorkerManagerOps, WorkerOps } from './ops';
export type { StoreInitOptions as WorkerInitOptions } from './ops';
type RealtimeWorkerClient = {
call<Op extends RealtimeRequestName>(
name: 'realtime.request',
payload: {
op: Op;
input: RealtimeRequestInputOf<Op>;
timeoutMs?: number;
}
): Promise<RealtimeRequestOutputOf<Op>>;
ob$<Topic extends RealtimeTopicName>(
name: 'realtime.subscribe',
payload: {
topic: Topic;
input: RealtimeTopicInputOf<Topic>;
}
): Observable<RealtimeTopicEventOf<Topic> | RealtimeSubscriptionReady>;
};
function realtimeAbortError(op: RealtimeRequestName) {
const error = new Error(`Realtime request aborted: ${op}`);
error.name = 'AbortError';
return error;
}
export class StoreManagerClient {
private readonly connections = new Map<
string,
@@ -49,9 +84,11 @@ export class StoreManagerClient {
constructor(private readonly client: OpClient<WorkerManagerOps>) {
this.telemetry = new TelemetryClient(this.client);
this.realtime = new RealtimeClient(this.client);
}
readonly telemetry: TelemetryClient;
readonly realtime: RealtimeClient;
open(key: string, options: StoreInitOptions) {
const { port1, port2 } = new MessageChannel();
@@ -138,6 +175,62 @@ class TelemetryClient {
}
}
export class RealtimeClient {
constructor(private readonly client: OpClient<WorkerManagerOps>) {}
configure(context: RealtimeConfigureInput): Promise<void> {
return this.client.call('realtime.configure', context);
}
request<Op extends RealtimeRequestName>(
op: Op,
input: RealtimeRequestInputOf<Op>,
options?: { timeoutMs?: number; signal?: AbortSignal }
): Promise<RealtimeRequestOutputOf<Op>> {
const request = (this.client as unknown as RealtimeWorkerClient).call(
'realtime.request',
{
op,
input,
timeoutMs: options?.timeoutMs,
}
);
if (!options?.signal) {
return request;
}
if (options.signal.aborted) {
return Promise.reject(realtimeAbortError(op));
}
let abortHandler: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
abortHandler = () => reject(realtimeAbortError(op));
options.signal?.addEventListener('abort', abortHandler, { once: true });
});
return Promise.race([request, aborted]).finally(() => {
if (abortHandler) {
options.signal?.removeEventListener('abort', abortHandler);
}
});
}
subscribe<Topic extends RealtimeTopicName>(
topic: Topic,
input: RealtimeTopicInputOf<Topic>
): Observable<RealtimeTopicEventOf<Topic> | RealtimeSubscriptionReady> {
return (this.client as unknown as RealtimeWorkerClient).ob$(
'realtime.subscribe',
{
topic,
input,
}
);
}
status(): Promise<RealtimeStatus> {
return this.client.call('realtime.status');
}
}
export class StoreClient {
constructor(private readonly client: OpClient<WorkerOps>) {
this.docStorage = new WorkerDocStorage(this.client);
@@ -2,6 +2,7 @@ import { OpConsumer } from '@toeverything/infra/op';
import { Observable } from 'rxjs';
import { type StorageConstructor } from '../impls';
import { RealtimeManager } from '../realtime';
import { SpaceStorage } from '../storage';
import type { AwarenessRecord } from '../storage/awareness';
import { Sync } from '../sync';
@@ -340,6 +341,7 @@ export class StoreManagerConsumer {
{ store: StoreConsumer; refCount: number }
>();
private readonly telemetry = new TelemetryManager();
private readonly realtime = new RealtimeManager();
constructor(
private readonly availableStorageImplementations: StorageConstructor[]
@@ -393,6 +395,12 @@ export class StoreManagerConsumer {
'telemetry.pageview': event => this.telemetry.pageview(event),
'telemetry.flush': () => this.telemetry.flush(),
'telemetry.getQueueState': () => this.telemetry.getQueueState(),
'realtime.configure': context => this.realtime.setContext(context),
'realtime.request': ({ op, input, timeoutMs }) =>
this.realtime.request(op, input, { timeoutMs }),
'realtime.subscribe': ({ topic, input }) =>
this.realtime.subscribe(topic, input),
'realtime.status': () => this.realtime.getStatus(),
});
}
}
+33
View File
@@ -1,3 +1,15 @@
import type {
RealtimeConfigureInput,
RealtimeRequestInputOf,
RealtimeRequestName,
RealtimeRequestOutputOf,
RealtimeStatus,
RealtimeSubscriptionReady,
RealtimeTopicEventOf,
RealtimeTopicInputOf,
RealtimeTopicName,
} from '@affine/realtime';
import type { AvailableStorageImplementations } from '../impls';
import type {
AggregateResult,
@@ -189,4 +201,25 @@ export type WorkerManagerOps = {
'telemetry.pageview': [TelemetryEvent, { queued: boolean }];
'telemetry.flush': [void, TelemetryAck];
'telemetry.getQueueState': [void, TelemetryQueueState];
'realtime.configure': [RealtimeConfigureInput, void];
'realtime.request': [
{
[Op in RealtimeRequestName]: {
op: Op;
input: RealtimeRequestInputOf<Op>;
timeoutMs?: number;
};
}[RealtimeRequestName],
RealtimeRequestOutputOf<RealtimeRequestName>,
];
'realtime.subscribe': [
{
[Topic in RealtimeTopicName]: {
topic: Topic;
input: RealtimeTopicInputOf<Topic>;
};
}[RealtimeTopicName],
RealtimeTopicEventOf<RealtimeTopicName> | RealtimeSubscriptionReady,
];
'realtime.status': [void, RealtimeStatus];
};