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

+12 -2
View File
@@ -59,20 +59,28 @@ export class TransportError extends Error {
}
}
export interface ChangesResponse {
headSeq: number;
changed: boolean;
}
export interface Transport {
snapshot(docId: string): Promise<SnapshotResponse>;
/** Long poll: resolves when the note changes, at the latest after waitSec seconds. */
changes(docId: string, since: number, waitSec: number, signal?: AbortSignal): Promise<ChangesResponse>;
sync(docId: string, body: SyncRequest, opts?: { keepalive?: boolean }): Promise<SyncResponse>;
leave(docId: string, clientId: string): Promise<void>;
}
const TIMEOUT_MS = 15000;
async function call<T>(path: string, init: RequestInit & { keepalive?: boolean } = {}): Promise<T> {
async function call<T>(path: string, init: RequestInit & { keepalive?: boolean } = {}, timeoutMs = TIMEOUT_MS): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const timer = setTimeout(() => controller.abort(), timeoutMs);
init.signal?.addEventListener('abort', () => controller.abort());
let res: Response;
try {
res = await fetch(path, { ...init, headers, cache: 'no-store', signal: init.keepalive ? undefined : controller.signal });
@@ -99,6 +107,8 @@ async function call<T>(path: string, init: RequestInit & { keepalive?: boolean }
export const httpTransport: Transport = {
snapshot: (docId) => call(`/api/v1/docs/${encodeURIComponent(docId)}/snapshot`),
changes: (docId, since, waitSec, signal) =>
call(`/api/v1/docs/${encodeURIComponent(docId)}/changes?since=${since}&wait=${waitSec}`, { signal }, (waitSec + 10) * 1000),
sync: (docId, body, opts) =>
call(`/api/v1/docs/${encodeURIComponent(docId)}/sync`, { method: 'POST', body: JSON.stringify(body), keepalive: opts?.keepalive }),
leave: async (docId, clientId) => {