feat(notes): browser sync engine with offline queue; editor saves through the operation log
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m26s

Phase 2 of the collaboration plan:
- frontend/src/lib/docSync: TypeScript port of the block tree, operations,
  effects and order keys (kept identical to Go via shared test vectors),
  diff from the editor's flat list to operations, IndexedDB persistence,
  HTTP transport and DocSyncEngine (one request in flight, idempotent
  resends, backoff, 3 s/30 s polling, keepalive flush and leave on exit,
  block leases for focus, 20 s grace and new blocks).
- Text changes carry the version the user saw when typing (first change
  wins when coalescing; a stale focused field keeps its old base), so a
  concurrent edit becomes a conflict copy instead of a silent overwrite.
- DocumentEditor uses the engine instead of the 800 ms whole-document PUT;
  editors (not only owners) can edit, remote changes appear live.
- Server: task IDs of linked blocks are delivered again, linked tasks follow
  ticked/deleted blocks after sync, note tags are derived, title changes
  notify other users; block IDs already used in another note are renamed
  (starter content with fixed IDs was empty for every further user).
- Fix: unlinking tasks from blocks wrote to non-existent columns and never
  worked (error was swallowed).
- Tests: Go/TS order key vectors, diff round trips, engine races (lost
  responses, offline + reload, edits arriving between typing and sending,
  stale focused field, duplicate first block), plus a Playwright test with
  two browsers against the real image (e2e/run.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 12:28:15 +01:00
1 parent 1ea0c78b41
commit 51f90eb7e4
31 files changed
+2468 -207

No files matched your search

+480
View File
@@ -0,0 +1,480 @@
// 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';
export type EngineStatus = 'loading' | 'saved' | 'saving' | 'offline' | 'error';
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
}
export interface EngineOptions {
transport?: Transport;
storage?: SyncStorage;
now?: () => number;
/** Disables timers and window listeners (tests drive the engine with syncNow()). */
manual?: boolean;
}
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;
const POLL_SOLO_MS = 30_000;
const BACKOFF_MAX_MS = 30_000;
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 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;
}
// --- 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.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);
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,
};
}
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 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);
// 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
this.enqueue([{ opId: this.newOpId(), kind: 'set_title', title, baseVersion: this.local.titleVersion }]);
}
/** Tells the engine which block the cursor is in (null = none); used for block leases. */
focusBlock(blockId: string | null): void {
const t = this.now();
if (this.focused) this.activity.set(this.focused, t);
this.focused = blockId;
if (blockId) this.activity.set(blockId, t);
}
/** 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 delay = this.pollDelay();
try {
await this.roundTrip();
this.backoff = 0;
if (this.syncAgain || this.pending.length > 0) delay = 0;
} catch (err) {
this.handleError(err);
delay = this.status === 'error' ? this.pollDelay() : this.nextBackoff();
} finally {
this.syncing = false;
this.syncAgain = false;
this.recompute();
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 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 pollDelay(): number {
return this.collaborative() ? POLL_COLLAB_MS : POLL_SOLO_MS;
}
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 = () => 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)
);
}
}