feat(notes): push, long polling and fallback detection for note sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s

Phase 4 of the collaboration plan:
- The sync engine listens for DOC_SEQ in the note's realtime room and syncs
  right away. Without WebSocket/SSE it long-polls /docs/{id}/changes (25 s);
  after three failures it falls back to polling every 3 s and retries long
  polling when the browser comes back online.
- With push or long polling the engine only syncs every 7 s while holding
  leases (renewal), otherwise every 30 s as a safety net. Focusing a block
  announces its lease immediately (otherwise it could take up to 30 s).
- Realtime connection: SSE counts as connected only after its first event;
  a stream that stays silent for 5 s (buffering proxy) switches SSE off for
  the tab. App-level PING/PONG detects WebSockets that are accepted but
  forward nothing and switches to SSE after 35 s.
- The editor shows the active channel for shared notes.
- Tests: engine channel tests (push, heartbeat, lease on focus, long
  polling, interval fallback, switching), realtime fallback tests with fake
  WebSocket/EventSource, Go tests for PONG and DOC_SEQ delivery, and
  e2e/channels.e2e.mjs measuring latency behind six simulated proxy setups
  (0.2-0.3 s on all live channels, ~3 s with plain polling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 14:10:29 +01:00
1 parent aa397c18cd
commit 8b7a53daab
18 files changed
+783 -37

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Minimal stand-ins: tests decide when connections open and which messages arrive.
class FakeWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances: FakeWebSocket[] = [];
readyState = FakeWebSocket.CONNECTING;
sent: string[] = [];
onopen: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onclose: ((e: { wasClean: boolean; code: number }) => void) | null = null;
onerror: ((e: unknown) => void) | null = null;
constructor(public url: string) {
FakeWebSocket.instances.push(this);
}
open() {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.();
}
send(data: string) {
this.sent.push(data);
}
close() {
this.readyState = FakeWebSocket.CLOSED;
}
}
class FakeEventSource {
static instances: FakeEventSource[] = [];
closed = false;
onopen: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onerror: ((e: unknown) => void) | null = null;
constructor(public url: string) {
FakeEventSource.instances.push(this);
}
open() {
this.onopen?.();
}
emit(event: string, payload: unknown = {}) {
this.onmessage?.({ data: JSON.stringify({ event, payload }) });
}
close() {
this.closed = true;
}
}
async function loadApi() {
vi.resetModules(); // fresh module state (transport flags) for every test
return import('../api');
}
describe('realtime fallback', () => {
beforeEach(() => {
vi.useFakeTimers();
FakeWebSocket.instances = [];
FakeEventSource.instances = [];
vi.stubGlobal('WebSocket', FakeWebSocket);
vi.stubGlobal('EventSource', FakeEventSource);
sessionStorage.clear();
localStorage.setItem('mindsift_jwt_token', 'test-token');
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('a WebSocket that stays silent is replaced by SSE', async () => {
const api = await loadApi();
const states: string[] = [];
api.onRealtimeStatus((connected, transport) => states.push(`${connected}:${transport}`));
api.connectWebSocket();
FakeWebSocket.instances[0].open();
expect(api.getRealtimeTransport()).toBe('ws');
await vi.advanceTimersByTimeAsync(15_000);
expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ event: 'PING' }));
await vi.advanceTimersByTimeAsync(40_000); // no message at all, not even PONG
expect(FakeEventSource.instances).toHaveLength(1);
FakeEventSource.instances[0].open();
FakeEventSource.instances[0].emit('SSE_CONNECTED', { sseId: 'sse_1' });
expect(api.getRealtimeTransport()).toBe('sse');
api.disconnectWebSocket();
});
it('a WebSocket answering PONG stays in use', async () => {
const api = await loadApi();
api.connectWebSocket();
const ws = FakeWebSocket.instances[0];
ws.open();
for (let i = 0; i < 6; i++) {
await vi.advanceTimersByTimeAsync(15_000);
ws.onmessage?.({ data: JSON.stringify({ event: 'PONG' }) });
}
expect(FakeEventSource.instances).toHaveLength(0);
expect(api.getRealtimeTransport()).toBe('ws');
api.disconnectWebSocket();
});
it('SSE only counts as connected once the first event arrives', async () => {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
const api = await loadApi();
api.connectWebSocket();
const es = FakeEventSource.instances[0];
es.open();
expect(api.isRealtimeConnected()).toBe(false);
es.emit('SSE_CONNECTED', { sseId: 'sse_1' });
expect(api.isRealtimeConnected()).toBe(true);
await vi.advanceTimersByTimeAsync(10_000);
expect(api.isRealtimeConnected()).toBe(true);
api.disconnectWebSocket();
});
it('a buffering proxy (stream opens, no events) switches SSE off for this tab', async () => {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
const api = await loadApi();
api.connectWebSocket();
const es = FakeEventSource.instances[0];
es.open();
await vi.advanceTimersByTimeAsync(5_500);
expect(es.closed).toBe(true);
expect(api.isRealtimeConnected()).toBe(false);
expect(sessionStorage.getItem('mindsift_sse_buffered')).toBe('true');
await vi.advanceTimersByTimeAsync(60_000);
expect(FakeEventSource.instances).toHaveLength(1); // no reconnect loop: note sync uses long polling
api.disconnectWebSocket();
});
});