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>
715 lines
26 KiB
TypeScript
715 lines
26 KiB
TypeScript
// DocSyncEngine keeps one note in sync with the server: confirmed state from the server log, plus the
|
||
// user's own unconfirmed operations on top. Every change is stored in IndexedDB before it is sent, sent
|
||
// with a unique op ID (safe to resend), and the server decides about conflicts. See
|
||
// Doku/plans/kollaboration-notizen.md.
|
||
import type { DocumentBlock } from '../types';
|
||
import { diffFlat, toDocumentBlocks } from './diff';
|
||
import { applyEffect, applyOp, cloneDoc, Doc, docFromSnapshot, emptyDoc, Op, Result } from './model';
|
||
import { defaultStorage, SyncStorage } from './persist';
|
||
import { httpTransport, LeaseInfo, Presence, Transport, TransportError } from './transport';
|
||
import { buildStep, failedChecks, touchedBlocks, UndoStep } from './undo';
|
||
import { PushChannel, PushKind, realtimePush } from './push';
|
||
|
||
export type EngineStatus = 'loading' | 'saved' | 'saving' | 'offline' | 'error';
|
||
|
||
/** How changes of others reach this client: push (WebSocket/SSE), long polling or plain polling. */
|
||
export type SyncChannel = 'ws' | 'sse' | 'longpoll' | 'interval';
|
||
|
||
export interface EngineState {
|
||
ready: boolean;
|
||
status: EngineStatus;
|
||
errorMessage?: string;
|
||
role: string; // owner | editor | viewer | '' (unknown yet)
|
||
title: string;
|
||
blocks: DocumentBlock[];
|
||
pendingCount: number;
|
||
shared: boolean;
|
||
sessions: number;
|
||
presence: Presence[];
|
||
lockedByOthers: LeaseInfo[];
|
||
conflicts: Result[]; // conflicts of own operations since start
|
||
canUndo: boolean;
|
||
canRedo: boolean;
|
||
channel: SyncChannel;
|
||
}
|
||
|
||
export interface ActionResult {
|
||
ok: boolean;
|
||
message?: string;
|
||
}
|
||
|
||
export interface EngineOptions {
|
||
transport?: Transport;
|
||
storage?: SyncStorage;
|
||
now?: () => number;
|
||
/** Disables timers, window listeners, push and long polling (tests drive the engine with syncNow()). */
|
||
manual?: boolean;
|
||
/** Push notifications; defaults to the app's realtime connection (not used in manual mode). */
|
||
push?: PushChannel;
|
||
}
|
||
|
||
const LEASE_GRACE_MS = 20_000; // keep a left block for this long
|
||
const IDLE_RELEASE_MS = 60_000; // release even the focused block after this long without input
|
||
const SYNC_DEBOUNCE_MS = 300;
|
||
const POLL_COLLAB_MS = 3_000; // shared note without push or long polling
|
||
const POLL_SOLO_MS = 30_000; // private note, opened once
|
||
const HEARTBEAT_MS = 7_000; // renews block leases while changes arrive by push/long polling
|
||
const SAFETY_POLL_MS = 30_000; // in case a push notification got lost
|
||
const LONG_POLL_WAIT_S = 25; // below typical proxy timeouts (nginx 60 s, Cloudflare 100 s)
|
||
const LONG_POLL_MAX_FAILURES = 3;
|
||
const BACKOFF_MAX_MS = 30_000;
|
||
const UNDO_LIMIT = 200;
|
||
const UNDO_GROUP_MS = 1_000; // keystrokes in the same block are one undo step until a pause this long
|
||
|
||
function randomHex(bytes: number): string {
|
||
const arr = new Uint8Array(bytes);
|
||
crypto.getRandomValues(arr);
|
||
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
/** Random block ID for new blocks (unique across clients). */
|
||
export function newBlockId(): string {
|
||
return `blk_${randomHex(10)}`;
|
||
}
|
||
|
||
/**
|
||
* Keeps only the last text change per block (and the last title change). The base version is the one of
|
||
* the first change: all later keystrokes build on what the user saw when starting to type, so a change
|
||
* from someone else in between is detected as a conflict instead of being overwritten.
|
||
*/
|
||
export function formBatch(pending: Op[]): Op[] {
|
||
const lastText = new Map<string, number>();
|
||
const firstTextBase = new Map<string, number | undefined>();
|
||
let lastTitle = -1;
|
||
let firstTitleBase: number | undefined;
|
||
pending.forEach((op, i) => {
|
||
if (op.kind === 'set_content') {
|
||
lastText.set(op.blockId!, i);
|
||
if (!firstTextBase.has(op.blockId!)) firstTextBase.set(op.blockId!, op.baseVersion);
|
||
}
|
||
if (op.kind === 'set_title') {
|
||
if (lastTitle < 0) firstTitleBase = op.baseVersion;
|
||
lastTitle = i;
|
||
}
|
||
});
|
||
const batch: Op[] = [];
|
||
pending.forEach((op, i) => {
|
||
if (op.kind === 'set_content') {
|
||
if (lastText.get(op.blockId!) !== i) return;
|
||
batch.push({ ...op, baseVersion: firstTextBase.get(op.blockId!) ?? 0 });
|
||
return;
|
||
}
|
||
if (op.kind === 'set_title') {
|
||
if (i !== lastTitle) return;
|
||
batch.push({ ...op, baseVersion: firstTitleBase ?? 0 });
|
||
return;
|
||
}
|
||
batch.push(op);
|
||
});
|
||
return batch;
|
||
}
|
||
|
||
export class DocSyncEngine {
|
||
readonly clientId = `c_${randomHex(8)}`;
|
||
private transport: Transport;
|
||
private storage: SyncStorage;
|
||
private now: () => number;
|
||
private manual: boolean;
|
||
|
||
private confirmed: Doc = emptyDoc();
|
||
private lastSeq = 0;
|
||
private pending: Op[] = [];
|
||
private inflight: Op[] | null = null;
|
||
private local: Doc = emptyDoc();
|
||
private opCounter = 0;
|
||
|
||
private ready = false;
|
||
private status: EngineStatus = 'loading';
|
||
private errorMessage?: string;
|
||
private role = '';
|
||
private shared = false;
|
||
private sessions = 0;
|
||
private presence: Presence[] = [];
|
||
private lockedByOthers: LeaseInfo[] = [];
|
||
private conflicts: Result[] = [];
|
||
private taskLinks = new Map<string, string>();
|
||
|
||
private focused: string | null = null;
|
||
private activity = new Map<string, number>(); // blockId -> last edit/focus time
|
||
// Version of each block the editor last showed in agreement with the engine. The focused text field does
|
||
// not take over remote changes while typing, so its edits are based on this version, not the newest one.
|
||
private uiSeen = new Map<string, number>();
|
||
private undoStack: UndoStep[] = [];
|
||
private redoStack: UndoStep[] = [];
|
||
|
||
private push: PushChannel | null;
|
||
private pushKind: PushKind = 'none';
|
||
private unsubscribePush: (() => void) | null = null;
|
||
private pokeTimer: ReturnType<typeof setTimeout> | null = null;
|
||
private longPollRunning = false;
|
||
private longPollBroken = false;
|
||
private longPollAbort: AbortController | null = null;
|
||
|
||
private syncing = false;
|
||
private syncAgain = false;
|
||
private stopped = false;
|
||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||
private backoff = 0;
|
||
private listeners = new Set<(s: EngineState) => void>();
|
||
private cleanup: Array<() => void> = [];
|
||
|
||
constructor(readonly docId: string, opts: EngineOptions = {}) {
|
||
this.transport = opts.transport || httpTransport;
|
||
this.storage = opts.storage || defaultStorage();
|
||
this.now = opts.now || (() => Date.now());
|
||
this.manual = !!opts.manual;
|
||
this.push = opts.push || (this.manual ? null : realtimePush);
|
||
}
|
||
|
||
// --- Public API ---
|
||
|
||
/** Loads the stored state (instant, works offline), then syncs with the server. */
|
||
async start(): Promise<void> {
|
||
try {
|
||
const stored = await this.storage.load(this.docId);
|
||
if (stored) {
|
||
this.confirmed = docFromSnapshot(stored.title, stored.titleVersion, stored.blocks);
|
||
this.lastSeq = stored.lastSeq;
|
||
this.role = stored.role || '';
|
||
this.pending = stored.pending || [];
|
||
this.inflight = stored.inflight || null;
|
||
this.ready = true;
|
||
this.recompute();
|
||
}
|
||
} catch {
|
||
// Storage unavailable: continue with the server state only
|
||
}
|
||
if (!this.manual) this.attachWindowListeners();
|
||
if (this.push) {
|
||
this.unsubscribePush = this.push.subscribe(
|
||
this.docId,
|
||
() => this.onPoke(),
|
||
(kind) => this.onPushStatus(kind)
|
||
);
|
||
}
|
||
if (!this.ready) {
|
||
try {
|
||
await this.loadSnapshot();
|
||
} catch (err) {
|
||
this.handleError(err);
|
||
this.emit();
|
||
this.schedule(this.nextBackoff());
|
||
return;
|
||
}
|
||
}
|
||
this.emit();
|
||
await this.syncNow();
|
||
}
|
||
|
||
/** Flushes pending changes (keepalive) and leaves the note. */
|
||
stop(): void {
|
||
if (this.stopped) return;
|
||
this.flushOnExit();
|
||
this.stopped = true;
|
||
if (this.timer) clearTimeout(this.timer);
|
||
if (this.pokeTimer) clearTimeout(this.pokeTimer);
|
||
this.longPollAbort?.abort();
|
||
this.unsubscribePush?.();
|
||
this.unsubscribePush = null;
|
||
this.cleanup.forEach((fn) => fn());
|
||
this.cleanup = [];
|
||
this.listeners.clear();
|
||
}
|
||
|
||
subscribe(fn: (s: EngineState) => void): () => void {
|
||
this.listeners.add(fn);
|
||
fn(this.getState());
|
||
return () => this.listeners.delete(fn);
|
||
}
|
||
|
||
getState(): EngineState {
|
||
return {
|
||
ready: this.ready,
|
||
status: this.status,
|
||
errorMessage: this.errorMessage,
|
||
role: this.role,
|
||
title: this.local.title,
|
||
blocks: toDocumentBlocks(this.local, this.docId, this.taskLinks),
|
||
pendingCount: this.pending.length + (this.inflight?.length || 0),
|
||
shared: this.shared,
|
||
sessions: this.sessions,
|
||
presence: this.presence,
|
||
lockedByOthers: this.lockedByOthers,
|
||
conflicts: this.conflicts,
|
||
canUndo: this.undoStack.length > 0,
|
||
canRedo: this.redoStack.length > 0,
|
||
channel: this.channel(),
|
||
};
|
||
}
|
||
|
||
get canEdit(): boolean {
|
||
return this.role === 'owner' || this.role === 'editor';
|
||
}
|
||
|
||
/** Task IDs of blocks linked to tasks (kept outside the synced tree). */
|
||
setTaskLinks(links: Map<string, string>): void {
|
||
this.taskLinks = new Map(links);
|
||
this.emit();
|
||
}
|
||
|
||
/** Records the editor's complete block list; differences become operations. */
|
||
applyFlat(blocks: DocumentBlock[]): void {
|
||
if (!this.ready || !this.canEdit) return;
|
||
for (const b of blocks) {
|
||
if (b.taskId) this.taskLinks.set(b.id, b.taskId);
|
||
}
|
||
for (const b of blocks) {
|
||
const cur = this.local.blocks.get(b.id);
|
||
if (cur && !cur.deleted && cur.content === (b.content || '')) this.uiSeen.set(b.id, cur.version);
|
||
}
|
||
const ops = diffFlat(this.local, blocks, () => this.newOpId());
|
||
if (ops.length === 0) return;
|
||
const before = this.local;
|
||
const t = this.now();
|
||
for (const op of ops) {
|
||
if ((op.kind === 'insert' || op.kind === 'set_content') && op.blockId) this.activity.set(op.blockId, t);
|
||
if (op.kind === 'set_content' && this.uiSeen.has(op.blockId!)) op.baseVersion = this.uiSeen.get(op.blockId!);
|
||
}
|
||
this.enqueue(ops);
|
||
this.record(before, ops);
|
||
// The editor now shows exactly the local state for these blocks
|
||
for (const op of ops) {
|
||
if (op.kind === 'set_content' || op.kind === 'insert') {
|
||
const cur = this.local.blocks.get(op.blockId!);
|
||
if (cur) this.uiSeen.set(op.blockId!, cur.version);
|
||
}
|
||
}
|
||
}
|
||
|
||
setTitle(title: string): void {
|
||
if (!this.ready || !this.canEdit || title === this.local.title) return;
|
||
// The title field always shows the engine's title, so the current local version is what the user saw
|
||
const before = this.local;
|
||
const ops: Op[] = [{ opId: this.newOpId(), kind: 'set_title', title, baseVersion: this.local.titleVersion }];
|
||
this.enqueue(ops);
|
||
this.record(before, ops);
|
||
}
|
||
|
||
/** Name of the user holding the lease on a block, if it is someone else. */
|
||
lockedBy(blockId: string): string | undefined {
|
||
return this.lockedByOthers.find((l) => l.blockId === blockId)?.name;
|
||
}
|
||
|
||
/** Reverts the user's last own change (skipped if someone else changed the blocks since). */
|
||
undo(): ActionResult {
|
||
return this.replay(this.undoStack, this.redoStack, 'Rückgängig');
|
||
}
|
||
|
||
/** Re-applies the last undone change. */
|
||
redo(): ActionResult {
|
||
return this.replay(this.redoStack, this.undoStack, 'Wiederholen');
|
||
}
|
||
|
||
/** Accepts (replaces the original with the conflict copy) or discards a conflict copy. */
|
||
resolveConflict(copyId: string, accept: boolean): ActionResult {
|
||
if (!this.ready || !this.canEdit) return { ok: false };
|
||
const copy = this.local.blocks.get(copyId);
|
||
if (!copy || copy.deleted || !copy.props.conflictOf) return { ok: false };
|
||
const target = copy.props.conflictOf;
|
||
// The author of the conflicting text holds the copy for a moment; deleting it now would fail half-way
|
||
const copyHolder = this.lockedBy(copyId);
|
||
if (copyHolder) return { ok: false, message: `${copyHolder} bearbeitet diese Version gerade – bitte kurz warten` };
|
||
const ops: Op[] = [];
|
||
if (!accept) {
|
||
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
|
||
} else if (target === 'title') {
|
||
ops.push({ opId: this.newOpId(), kind: 'set_title', title: copy.content, baseVersion: this.local.titleVersion });
|
||
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
|
||
} else {
|
||
const original = this.local.blocks.get(target);
|
||
if (original && !original.deleted) {
|
||
const holder = this.lockedBy(target);
|
||
if (holder) return { ok: false, message: `Der ursprüngliche Punkt wird gerade von ${holder} bearbeitet` };
|
||
ops.push({ opId: this.newOpId(), kind: 'set_content', blockId: target, content: copy.content, baseVersion: original.version });
|
||
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
|
||
} else {
|
||
// The original is gone: the copy simply becomes a normal block
|
||
ops.push({ opId: this.newOpId(), kind: 'set_props', blockId: copyId, patch: { clearConflict: true } });
|
||
}
|
||
}
|
||
const before = this.local;
|
||
this.enqueue(ops);
|
||
this.record(before, ops);
|
||
return { ok: true };
|
||
}
|
||
|
||
/** Tells the engine which block the cursor is in (null = none); used for block leases. */
|
||
focusBlock(blockId: string | null): void {
|
||
const t = this.now();
|
||
const changed = blockId !== this.focused;
|
||
if (this.focused) this.activity.set(this.focused, t);
|
||
this.focused = blockId;
|
||
if (blockId) this.activity.set(blockId, t);
|
||
// Announce the lease right away: with push the next regular sync may be up to 30 s away
|
||
if (changed && blockId && this.canEdit && this.collaborative()) {
|
||
if (this.syncing) this.syncAgain = true;
|
||
else this.schedule(SYNC_DEBOUNCE_MS);
|
||
}
|
||
}
|
||
|
||
/** Runs one sync round trip now (or right after the running one). */
|
||
async syncNow(): Promise<void> {
|
||
if (this.stopped) return;
|
||
if (this.syncing) {
|
||
this.syncAgain = true;
|
||
return;
|
||
}
|
||
if (this.timer) {
|
||
clearTimeout(this.timer);
|
||
this.timer = null;
|
||
}
|
||
this.syncing = true;
|
||
let failed = false;
|
||
try {
|
||
await this.roundTrip();
|
||
this.backoff = 0;
|
||
} catch (err) {
|
||
this.handleError(err);
|
||
failed = true;
|
||
} finally {
|
||
this.syncing = false;
|
||
this.recompute();
|
||
if (!failed) this.ensureLongPoll();
|
||
// Decided only now: the response tells whether the note is shared and how changes arrive
|
||
const delay = failed
|
||
? this.status === 'error'
|
||
? this.pollDelay()
|
||
: this.nextBackoff()
|
||
: this.syncAgain || this.pending.length > 0
|
||
? 0
|
||
: this.pollDelay();
|
||
this.syncAgain = false;
|
||
this.emit();
|
||
this.schedule(delay);
|
||
}
|
||
}
|
||
|
||
// --- Internals ---
|
||
|
||
private newOpId(): string {
|
||
this.opCounter++;
|
||
return `${this.clientId}.${this.opCounter}`;
|
||
}
|
||
|
||
private enqueue(ops: Op[]): void {
|
||
this.pending.push(...ops);
|
||
this.recompute();
|
||
if (this.status !== 'offline' && this.status !== 'error') this.status = 'saving';
|
||
this.emit();
|
||
// Store first, then send: typed text must survive a crash between the two
|
||
void this.persist().finally(() => this.schedule(SYNC_DEBOUNCE_MS));
|
||
}
|
||
|
||
private record(before: Doc, ops: Op[]): void {
|
||
const step = buildStep(before, ops, this.now());
|
||
if (!step) return;
|
||
this.redoStack = [];
|
||
const top = this.undoStack[this.undoStack.length - 1];
|
||
if (step.textKey && top && top.textKey === step.textKey && step.at - top.at < UNDO_GROUP_MS) {
|
||
// Same block, still typing: keep the original text as undo target, update the expected result
|
||
top.checks = step.checks;
|
||
top.at = step.at;
|
||
} else {
|
||
this.undoStack.push(step);
|
||
if (this.undoStack.length > UNDO_LIMIT) this.undoStack.shift();
|
||
}
|
||
this.emit();
|
||
}
|
||
|
||
private replay(from: UndoStep[], to: UndoStep[], label: string): ActionResult {
|
||
if (!this.ready || !this.canEdit) return { ok: false };
|
||
const step = from.pop();
|
||
if (!step) return { ok: false };
|
||
if (failedChecks(this.local, step).length > 0) {
|
||
this.emit();
|
||
return { ok: false, message: `${label} übersprungen: Der Punkt wurde inzwischen von jemand anderem geändert` };
|
||
}
|
||
const holder = touchedBlocks(step).map((id) => this.lockedBy(id)).find(Boolean);
|
||
if (holder) {
|
||
this.emit();
|
||
return { ok: false, message: `${label} übersprungen: ${holder} bearbeitet den Punkt gerade` };
|
||
}
|
||
const ops: Op[] = step.inverse.map((o) => {
|
||
const op: Op = { ...o, opId: this.newOpId() } as Op;
|
||
if (op.kind === 'set_content') op.baseVersion = this.local.blocks.get(op.blockId!)?.version ?? 0;
|
||
if (op.kind === 'set_title') op.baseVersion = this.local.titleVersion;
|
||
return op;
|
||
});
|
||
const before = this.local;
|
||
this.enqueue(ops);
|
||
const back = buildStep(before, ops, this.now());
|
||
if (back) {
|
||
back.textKey = undefined;
|
||
to.push(back);
|
||
}
|
||
for (const op of ops) {
|
||
const b = op.blockId ? this.local.blocks.get(op.blockId) : undefined;
|
||
if (b) this.uiSeen.set(b.id, b.version);
|
||
}
|
||
this.emit();
|
||
return { ok: true };
|
||
}
|
||
|
||
private recompute(): void {
|
||
const v = cloneDoc(this.confirmed);
|
||
for (const op of [...(this.inflight || []), ...this.pending]) {
|
||
const local = { ...op };
|
||
if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0;
|
||
if (local.kind === 'set_title') local.baseVersion = v.titleVersion;
|
||
applyOp(v, local);
|
||
}
|
||
this.local = v;
|
||
}
|
||
|
||
private emit(): void {
|
||
// Unfocused blocks take over whatever the engine shows; only the focused text field may lag behind
|
||
this.local.blocks.forEach((b, id) => {
|
||
if (id !== this.focused) this.uiSeen.set(id, b.version);
|
||
});
|
||
if (this.listeners.size === 0) return;
|
||
const s = this.getState();
|
||
this.listeners.forEach((fn) => fn(s));
|
||
}
|
||
|
||
private async persist(): Promise<void> {
|
||
try {
|
||
await this.storage.save({
|
||
docId: this.docId,
|
||
title: this.confirmed.title,
|
||
titleVersion: this.confirmed.titleVersion,
|
||
lastSeq: this.lastSeq,
|
||
role: this.role,
|
||
blocks: Array.from(this.confirmed.blocks.values()),
|
||
pending: this.pending,
|
||
inflight: this.inflight,
|
||
savedAt: this.now(),
|
||
});
|
||
} catch {
|
||
// Storage full or unavailable: changes stay in memory and are still sent
|
||
}
|
||
}
|
||
|
||
private async loadSnapshot(): Promise<void> {
|
||
const snap = await this.transport.snapshot(this.docId);
|
||
this.confirmed = docFromSnapshot(snap.title, snap.titleVersion, snap.blocks || []);
|
||
this.lastSeq = snap.headSeq;
|
||
this.role = snap.role;
|
||
this.shared = snap.shared;
|
||
this.ready = true;
|
||
this.recompute();
|
||
await this.persist();
|
||
}
|
||
|
||
private holds(): string[] {
|
||
const t = this.now();
|
||
const out: string[] = [];
|
||
this.activity.forEach((at, id) => {
|
||
const fresh = t - at < LEASE_GRACE_MS || (id === this.focused && t - at < IDLE_RELEASE_MS);
|
||
if (fresh && this.local.blocks.has(id)) out.push(id);
|
||
else if (!fresh) this.activity.delete(id);
|
||
});
|
||
return out.sort();
|
||
}
|
||
|
||
private collaborative(): boolean {
|
||
return this.shared || this.sessions > 1;
|
||
}
|
||
|
||
private async roundTrip(): Promise<void> {
|
||
if (!this.ready) await this.loadSnapshot();
|
||
if (!this.inflight && this.pending.length > 0 && this.canEdit) {
|
||
this.inflight = formBatch(this.pending);
|
||
this.pending = [];
|
||
await this.persist();
|
||
}
|
||
const ops = this.canEdit ? this.inflight || [] : [];
|
||
if (ops.length > 0) this.status = 'saving';
|
||
|
||
for (;;) {
|
||
const res = await this.transport.sync(this.docId, {
|
||
clientId: this.clientId,
|
||
lastSeq: this.lastSeq,
|
||
ops: this.inflight ? ops : [],
|
||
leases: this.canEdit && this.collaborative() ? { hold: this.holds(), release: [] } : undefined,
|
||
});
|
||
if (this.inflight) {
|
||
const sent = new Map(this.inflight.map((op) => [op.opId, op]));
|
||
for (const r of res.results) {
|
||
if (r.status === 'conflict') this.conflicts = [...this.conflicts, r];
|
||
// Someone else created a block with the same ID first (e.g. the first block of an empty note):
|
||
// keep the typed text by sending it as a regular text change of that block
|
||
const op = sent.get(r.opId);
|
||
if (r.status === 'rejected' && r.reason === 'exists' && op?.kind === 'insert' && op.content) {
|
||
// Base 0: the user never saw the other client's version of this block
|
||
this.pending.unshift({ opId: this.newOpId(), kind: 'set_content', blockId: op.blockId, content: op.content, baseVersion: 0 });
|
||
}
|
||
}
|
||
this.inflight = null;
|
||
}
|
||
this.role = res.role;
|
||
this.shared = res.shared;
|
||
this.sessions = res.sessions;
|
||
this.presence = res.presence || [];
|
||
this.lockedByOthers = res.leases?.others || [];
|
||
|
||
let gap = !!res.resync;
|
||
for (const entry of res.ops) {
|
||
if (entry.seq <= this.lastSeq) continue;
|
||
if (entry.seq !== this.lastSeq + 1) {
|
||
gap = true;
|
||
break;
|
||
}
|
||
applyEffect(this.confirmed, entry.effect);
|
||
this.lastSeq = entry.seq;
|
||
}
|
||
if (gap) await this.loadSnapshot();
|
||
await this.persist();
|
||
if (!res.hasMore || gap) break;
|
||
}
|
||
this.errorMessage = undefined;
|
||
this.status = this.pending.length > 0 ? 'saving' : 'saved';
|
||
}
|
||
|
||
private handleError(err: unknown): void {
|
||
if (err instanceof TransportError && (err.status === 403 || err.status === 404)) {
|
||
// Access lost: keep the queue (nothing is thrown away), but stop sending
|
||
this.status = 'error';
|
||
this.errorMessage = err.message;
|
||
if (err.status === 404) this.role = '';
|
||
else if (this.role !== 'owner') this.role = 'viewer';
|
||
return;
|
||
}
|
||
this.status = 'offline';
|
||
this.errorMessage = err instanceof Error ? err.message : String(err);
|
||
}
|
||
|
||
private channel(): SyncChannel {
|
||
if (this.pushKind !== 'none') return this.pushKind;
|
||
if (this.longPollRunning && !this.longPollBroken) return 'longpoll';
|
||
return 'interval';
|
||
}
|
||
|
||
private pollDelay(): number {
|
||
if (!this.collaborative()) return POLL_SOLO_MS;
|
||
if (this.channel() === 'interval') return POLL_COLLAB_MS;
|
||
return this.canEdit && this.holds().length > 0 ? HEARTBEAT_MS : SAFETY_POLL_MS;
|
||
}
|
||
|
||
private onPoke(): void {
|
||
if (this.pokeTimer || this.stopped) return;
|
||
// Several notifications in a row (e.g. typing of others) lead to one sync
|
||
this.pokeTimer = setTimeout(() => {
|
||
this.pokeTimer = null;
|
||
void this.syncNow();
|
||
}, 50);
|
||
}
|
||
|
||
private onPushStatus(kind: PushKind): void {
|
||
const wasLive = this.pushKind !== 'none';
|
||
this.pushKind = kind;
|
||
if (kind !== 'none') {
|
||
this.longPollAbort?.abort();
|
||
if (!wasLive) void this.syncNow(); // catch up on what was missed while disconnected
|
||
} else {
|
||
this.ensureLongPoll();
|
||
if (!this.syncing) this.schedule(this.pollDelay());
|
||
}
|
||
this.emit();
|
||
}
|
||
|
||
/** Starts long polling when there is no push channel (e.g. a proxy blocks WebSocket and SSE). */
|
||
private ensureLongPoll(): void {
|
||
if (this.manual || this.stopped || !this.ready || this.longPollRunning || this.longPollBroken) return;
|
||
if (this.pushKind !== 'none' || !this.collaborative() || this.status === 'error') return;
|
||
void this.longPollLoop();
|
||
}
|
||
|
||
private async longPollLoop(): Promise<void> {
|
||
this.longPollRunning = true;
|
||
this.emit();
|
||
let failures = 0;
|
||
try {
|
||
while (!this.stopped && this.pushKind === 'none' && this.collaborative() && this.status !== 'error') {
|
||
const ctrl = new AbortController();
|
||
this.longPollAbort = ctrl;
|
||
try {
|
||
const res = await this.transport.changes(this.docId, this.lastSeq, LONG_POLL_WAIT_S, ctrl.signal);
|
||
failures = 0;
|
||
if (res.changed || res.headSeq !== this.lastSeq) await this.syncNow();
|
||
} catch {
|
||
if (ctrl.signal.aborted || this.stopped) break;
|
||
failures++;
|
||
if (failures >= LONG_POLL_MAX_FAILURES) {
|
||
// Long polling does not get through either: plain polling every 3 s remains
|
||
this.longPollBroken = true;
|
||
break;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 1000 * failures));
|
||
}
|
||
}
|
||
} finally {
|
||
this.longPollRunning = false;
|
||
this.longPollAbort = null;
|
||
this.emit();
|
||
if (!this.syncing && !this.stopped) this.schedule(this.pollDelay());
|
||
}
|
||
}
|
||
|
||
private nextBackoff(): number {
|
||
this.backoff = this.backoff ? Math.min(this.backoff * 2, BACKOFF_MAX_MS) : 1000;
|
||
return this.backoff;
|
||
}
|
||
|
||
private schedule(delay: number): void {
|
||
if (this.manual || this.stopped) return;
|
||
if (this.timer) clearTimeout(this.timer);
|
||
this.timer = setTimeout(() => {
|
||
this.timer = null;
|
||
void this.syncNow();
|
||
}, delay);
|
||
}
|
||
|
||
/** Sends what is pending with keepalive (survives page unload) and releases leases. */
|
||
private flushOnExit(): void {
|
||
if (this.canEdit && (this.inflight || this.pending.length > 0)) {
|
||
const ops = this.inflight || formBatch(this.pending);
|
||
this.transport
|
||
.sync(this.docId, { clientId: this.clientId, lastSeq: this.lastSeq, ops }, { keepalive: true })
|
||
.catch(() => {
|
||
// Not delivered: the queue is in IndexedDB and is resent on the next visit (same op IDs)
|
||
});
|
||
}
|
||
this.transport.leave(this.docId, this.clientId).catch(() => {});
|
||
}
|
||
|
||
private attachWindowListeners(): void {
|
||
const onOnline = () => {
|
||
this.longPollBroken = false; // the network changed: long polling may work again
|
||
void this.syncNow();
|
||
};
|
||
const onVisibility = () => {
|
||
if (document.visibilityState === 'hidden') this.flushOnExit();
|
||
else void this.syncNow();
|
||
};
|
||
const onPageHide = () => this.flushOnExit();
|
||
window.addEventListener('online', onOnline);
|
||
document.addEventListener('visibilitychange', onVisibility);
|
||
window.addEventListener('pagehide', onPageHide);
|
||
this.cleanup.push(
|
||
() => window.removeEventListener('online', onOnline),
|
||
() => document.removeEventListener('visibilitychange', onVisibility),
|
||
() => window.removeEventListener('pagehide', onPageHide)
|
||
);
|
||
}
|
||
}
|