feat(core): migrate more pull to realtime (#14936)

#### PR Dependency Tree


* **PR #14936** 👈

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

* **Refactor**
* Consolidated realtime subscription patterns for consistent, more
reliable live updates across comments, notifications, transcription
tasks, and embedding progress.
* Standardized realtime room naming and subscription keys for
deterministic delivery.

* **New Features**
* Introduced a reusable live-query mechanism powering realtime snapshot
+ event workflows used by comments, notifications, transcript tasks, and
embedding progress.

* **Tests**
* Added tests covering live-query behavior and deterministic
subscription key generation.

[![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/14936)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-05-11 00:33:25 +08:00
committed by GitHub
parent 8cf00738c2
commit db0ff0a9df
26 changed files with 812 additions and 441 deletions
@@ -1,7 +1,7 @@
import type { RealtimeEvent } from '@affine/realtime';
import { getRealtimeInputKey, type RealtimeEvent } from '@affine/realtime';
import { beforeEach, expect, test, vi } from 'vitest';
import { RealtimeManager, stableStringify } from '../manager';
import { RealtimeManager } from '../manager';
type Handler = (payload?: unknown) => void;
@@ -70,16 +70,16 @@ beforeEach(() => {
socket.disconnected = false;
});
test('stableStringify is deterministic for realtime subscription inputs', () => {
expect(stableStringify({ workspaceId: 'space', docId: 'doc' })).toBe(
stableStringify({ docId: 'doc', workspaceId: 'space' })
test('getRealtimeInputKey is deterministic for realtime subscription inputs', () => {
expect(getRealtimeInputKey({ workspaceId: 'space', docId: 'doc' })).toBe(
getRealtimeInputKey({ 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(
test('getRealtimeInputKey follows JSON semantics for edge values', () => {
expect(getRealtimeInputKey({ a: undefined })).toBe(getRealtimeInputKey({}));
expect(getRealtimeInputKey([undefined])).toBe('[null]');
expect(getRealtimeInputKey(new Date('2026-01-02T03:04:05.000Z'))).toBe(
'"2026-01-02T03:04:05.000Z"'
);
});
@@ -159,13 +159,13 @@ test('subscribe routes events by topic and stable input key', async () => {
socket.emit('realtime:event', {
topic: 'comment.changed',
inputKey: stableStringify({ workspaceId: 'space', docId: 'other' }),
inputKey: getRealtimeInputKey({ workspaceId: 'space', docId: 'other' }),
sentAt: 1,
event: { changed: true },
} satisfies RealtimeEvent);
socket.emit('realtime:event', {
topic: 'comment.changed',
inputKey: stableStringify({ workspaceId: 'space', docId: 'doc' }),
inputKey: getRealtimeInputKey({ workspaceId: 'space', docId: 'doc' }),
sentAt: 2,
event: { changed: true },
} satisfies RealtimeEvent);
@@ -1 +1 @@
export { RealtimeManager, stableStringify } from './manager';
export { RealtimeManager } from './manager';
@@ -10,6 +10,7 @@ import type {
RealtimeTopicInputOf,
RealtimeTopicName,
} from '@affine/realtime';
import { getRealtimeInputKey } from '@affine/realtime';
import { Observable, Subject } from 'rxjs';
import { SocketConnection } from '../impls/cloud/socket';
@@ -151,7 +152,7 @@ export class RealtimeManager {
this.subscriptions.set(subscriptionId, {
topic,
input,
inputKey: stableStringify(input),
inputKey: getRealtimeInputKey(input),
subject$,
});
subscriber.next({
@@ -302,35 +303,3 @@ export class RealtimeManager {
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(',')}}`;
}