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
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:
1 parent
1ea0c78b41
commit
51f90eb7e4
31 files changed
+2468
-207
No files matched your search
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { DocumentBlock } from '../../types';
|
||||
import { diffFlat, toDocumentBlocks } from '../diff';
|
||||
import { applyEffect, applyOp, cloneDoc, docsEqual, emptyDoc, Doc } from '../model';
|
||||
|
||||
let opSeq = 0;
|
||||
const nextOp = () => `op${++opSeq}`;
|
||||
|
||||
function build(blocks: DocumentBlock[]): Doc {
|
||||
const d = emptyDoc();
|
||||
for (const op of diffFlat(d, blocks, nextOp)) applyOp(d, op);
|
||||
return d;
|
||||
}
|
||||
|
||||
const B = (id: string, level: number, content = id, extra: Partial<DocumentBlock> = {}): DocumentBlock => ({
|
||||
id,
|
||||
level,
|
||||
content,
|
||||
type: 'bullet',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const shape = (blocks: DocumentBlock[]) => blocks.map((b) => [b.id, b.level || 0, b.content, !!b.checked, b.type]);
|
||||
|
||||
describe('diffFlat', () => {
|
||||
it('builds the tree from levels and produces no ops for an unchanged list', () => {
|
||||
const list = [B('a', 0), B('b', 1), B('c', 2), B('d', 1), B('e', 0)];
|
||||
const d = build(list);
|
||||
expect(shape(toDocumentBlocks(d, 'doc'))).toEqual(shape(list));
|
||||
expect(diffFlat(d, toDocumentBlocks(d, 'doc'), nextOp)).toEqual([]);
|
||||
});
|
||||
|
||||
it('turns typing into a single set_content without moves', () => {
|
||||
const d = build([B('a', 0), B('b', 0), B('c', 0)]);
|
||||
const ops = diffFlat(d, [B('a', 0), B('b', 0, 'b geändert'), B('c', 0)], nextOp);
|
||||
expect(ops.map((o) => o.kind)).toEqual(['set_content']);
|
||||
});
|
||||
|
||||
it('inserting in the middle does not move the neighbours', () => {
|
||||
const d = build([B('a', 0), B('c', 0)]);
|
||||
const ops = diffFlat(d, [B('a', 0), B('b', 0), B('c', 0)], nextOp);
|
||||
expect(ops.map((o) => o.kind)).toEqual(['insert']);
|
||||
});
|
||||
|
||||
it('reaches any target list (random edits)', () => {
|
||||
let seed = 99;
|
||||
const rnd = (n: number) => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return Math.floor((seed / 2147483648) * n);
|
||||
};
|
||||
let current: DocumentBlock[] = [B('x0', 0)];
|
||||
let d = build(current);
|
||||
for (let step = 0; step < 300; step++) {
|
||||
const next = current.map((b) => ({ ...b }));
|
||||
const i = rnd(next.length);
|
||||
switch (rnd(6)) {
|
||||
case 0:
|
||||
next.splice(i + 1, 0, B(`x${step + 1}`, next[i] ? next[i].level || 0 : 0));
|
||||
break;
|
||||
case 1:
|
||||
if (next.length > 1) next.splice(i, 1);
|
||||
break;
|
||||
case 2:
|
||||
next[i].content += '!';
|
||||
break;
|
||||
case 3:
|
||||
next[i].level = (next[i].level || 0) + 1;
|
||||
break;
|
||||
case 4:
|
||||
next[i].level = Math.max(0, (next[i].level || 0) - 1);
|
||||
break;
|
||||
case 5: {
|
||||
const [moved] = next.splice(i, 1);
|
||||
next.splice(rnd(next.length + 1), 0, moved);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Normalise like the editor would (levels can only grow by one)
|
||||
let prev = -1;
|
||||
for (const b of next) {
|
||||
b.level = Math.min(b.level || 0, prev + 1);
|
||||
prev = b.level;
|
||||
}
|
||||
const before = cloneDoc(d);
|
||||
const replay = cloneDoc(d);
|
||||
for (const op of diffFlat(d, next, nextOp)) {
|
||||
const { effects } = applyOp(d, op);
|
||||
effects.forEach((e) => applyEffect(replay, e));
|
||||
}
|
||||
expect(shape(toDocumentBlocks(d, 'doc'))).toEqual(shape(next));
|
||||
expect(docsEqual(d, replay)).toBe(true);
|
||||
expect(before).not.toBe(d);
|
||||
current = next;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { DocumentBlock } from '../../types';
|
||||
import { DocSyncEngine, formBatch } from '../engine';
|
||||
import { applyOp, cloneDoc, Doc, emptyDoc, Op, Result } from '../model';
|
||||
import { IndexedDBStorage, MemoryStorage } from '../persist';
|
||||
import { LogEntry, SnapshotResponse, SyncRequest, SyncResponse, Transport, TransportError } from '../transport';
|
||||
|
||||
/** In-memory stand-in for the Go server: same operation rules, log and idempotency. */
|
||||
class FakeServer {
|
||||
doc: Doc = emptyDoc();
|
||||
log: LogEntry[] = [];
|
||||
results = new Map<string, Result>();
|
||||
roles = new Map<string, string>(); // clientId -> role
|
||||
applyCount = new Map<string, number>();
|
||||
|
||||
constructor(initial: { id: string; content: string }[] = [{ id: 'b1', content: 'Start' }]) {
|
||||
let key = 'a0';
|
||||
for (const b of initial) {
|
||||
this.apply({ opId: `seed-${b.id}`, kind: 'insert', blockId: b.id, parentId: '', orderKey: key, content: b.content }, 'seed');
|
||||
key = String.fromCharCode(key.charCodeAt(0)) + String.fromCharCode(key.charCodeAt(1) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
apply(op: Op, clientId: string): Result {
|
||||
const known = this.results.get(op.opId);
|
||||
if (known) return known;
|
||||
this.applyCount.set(op.opId, (this.applyCount.get(op.opId) || 0) + 1);
|
||||
const { effects, result } = applyOp(this.doc, op, clientId);
|
||||
for (const e of effects) this.log.push({ seq: this.log.length + 1, userId: clientId, clientId, opId: op.opId, effect: e });
|
||||
if (effects.length > 0) result.seq = this.log.length;
|
||||
this.results.set(op.opId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
snapshot(): SnapshotResponse {
|
||||
return {
|
||||
docId: 'doc',
|
||||
title: this.doc.title,
|
||||
titleVersion: this.doc.titleVersion,
|
||||
headSeq: this.log.length,
|
||||
blocks: Array.from(cloneDoc(this.doc).blocks.values()),
|
||||
role: 'owner',
|
||||
shared: true,
|
||||
};
|
||||
}
|
||||
|
||||
sync(body: SyncRequest): SyncResponse {
|
||||
const role = this.roles.get(body.clientId) || 'owner';
|
||||
if (body.ops.length > 0 && role === 'viewer') throw new TransportError('nur Lesen', 403);
|
||||
const results = body.ops.map((op) => this.apply(op, body.clientId));
|
||||
return {
|
||||
results,
|
||||
ops: this.log.filter((e) => e.seq > body.lastSeq),
|
||||
headSeq: this.log.length,
|
||||
hasMore: false,
|
||||
role,
|
||||
shared: true,
|
||||
sessions: 2,
|
||||
presence: [],
|
||||
leases: { granted: [], denied: [], others: [] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Network between one client and the fake server, with switchable failures. */
|
||||
class FakeNet implements Transport {
|
||||
mode: 'ok' | 'down' | 'lose-response' = 'ok';
|
||||
duringRequest: (() => void) | null = null; // runs while the request is "on the wire"
|
||||
|
||||
constructor(private server: FakeServer, private role = 'owner') {}
|
||||
async snapshot() {
|
||||
if (this.mode === 'down') throw new TransportError('offline', 0);
|
||||
return { ...this.server.snapshot(), role: this.role };
|
||||
}
|
||||
async sync(_docId: string, body: SyncRequest) {
|
||||
if (this.mode === 'down') throw new TransportError('offline', 0);
|
||||
this.server.roles.set(body.clientId, this.role);
|
||||
const hook = this.duringRequest;
|
||||
this.duringRequest = null;
|
||||
hook?.();
|
||||
const res = this.server.sync(body);
|
||||
if (this.mode === 'lose-response') throw new TransportError('timeout', 0);
|
||||
return res;
|
||||
}
|
||||
async leave() {}
|
||||
}
|
||||
|
||||
function flatOf(engine: DocSyncEngine): DocumentBlock[] {
|
||||
return engine.getState().blocks;
|
||||
}
|
||||
|
||||
function edit(engine: DocSyncEngine, fn: (blocks: DocumentBlock[]) => DocumentBlock[]) {
|
||||
engine.applyFlat(fn(flatOf(engine).map((b) => ({ ...b }))));
|
||||
}
|
||||
|
||||
const contents = (engine: DocSyncEngine) => flatOf(engine).map((b) => b.content);
|
||||
const serverContents = (server: FakeServer) => docContents(server.doc);
|
||||
function docContents(d: Doc): string[] {
|
||||
return Array.from(d.blocks.values())
|
||||
.filter((b) => !b.deleted)
|
||||
.sort((a, b) => (a.orderKey < b.orderKey ? -1 : a.orderKey > b.orderKey ? 1 : a.id < b.id ? -1 : 1))
|
||||
.map((b) => b.content);
|
||||
}
|
||||
|
||||
async function startEngine(net: Transport, storage = new MemoryStorage()) {
|
||||
const engine = new DocSyncEngine('doc', { transport: net, storage, manual: true });
|
||||
await engine.start();
|
||||
return engine;
|
||||
}
|
||||
|
||||
describe('DocSyncEngine', () => {
|
||||
it('sends edits and confirms them', async () => {
|
||||
const server = new FakeServer();
|
||||
const engine = await startEngine(new FakeNet(server));
|
||||
edit(engine, (b) => {
|
||||
b[0].content = 'Hallo';
|
||||
return [...b, { id: 'b2', type: 'bullet', content: 'Neu', level: 1 }];
|
||||
});
|
||||
expect(engine.getState().pendingCount).toBeGreaterThan(0);
|
||||
await engine.syncNow();
|
||||
expect(engine.getState().status).toBe('saved');
|
||||
expect(engine.getState().pendingCount).toBe(0);
|
||||
expect(serverContents(server)).toEqual(['Hallo', 'Neu']);
|
||||
});
|
||||
|
||||
it('coalesces fast typing into one text operation', async () => {
|
||||
const server = new FakeServer();
|
||||
const engine = await startEngine(new FakeNet(server));
|
||||
for (const text of ['H', 'Ha', 'Hal', 'Hall', 'Hallo']) edit(engine, (b) => [{ ...b[0], content: text }]);
|
||||
await engine.syncNow();
|
||||
const textOps = server.log.filter((e) => e.effect.kind === 'set_content');
|
||||
expect(textOps).toHaveLength(1);
|
||||
expect(textOps[0].effect.content).toBe('Hallo');
|
||||
});
|
||||
|
||||
it('keeps edits through an offline phase and a reload, and applies them exactly once', async () => {
|
||||
const server = new FakeServer();
|
||||
const storage = new MemoryStorage();
|
||||
const net = new FakeNet(server);
|
||||
const first = await startEngine(net, storage);
|
||||
|
||||
net.mode = 'down';
|
||||
edit(first, (b) => [{ ...b[0], content: 'Offline getippt' }, { id: 'b9', type: 'bullet', content: 'Noch einer', level: 0 }]);
|
||||
await new Promise((r) => setTimeout(r, 0)); // let the queue reach storage
|
||||
await first.syncNow();
|
||||
expect(first.getState().status).toBe('offline');
|
||||
expect(contents(first)).toEqual(['Offline getippt', 'Noch einer']);
|
||||
|
||||
// Tab is closed while offline; a new tab opens the note from storage, still offline
|
||||
const second = await startEngine(net, storage);
|
||||
expect(contents(second)).toEqual(['Offline getippt', 'Noch einer']);
|
||||
|
||||
net.mode = 'ok';
|
||||
await second.syncNow();
|
||||
await second.syncNow();
|
||||
expect(serverContents(server)).toEqual(['Offline getippt', 'Noch einer']);
|
||||
for (const count of server.applyCount.values()) expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('retries after a lost response without applying twice', async () => {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const engine = await startEngine(net);
|
||||
edit(engine, (b) => [...b, { id: 'b2', type: 'bullet', content: 'Einmal', level: 0 }]);
|
||||
net.mode = 'lose-response';
|
||||
await engine.syncNow();
|
||||
expect(engine.getState().status).toBe('offline');
|
||||
net.mode = 'ok';
|
||||
await engine.syncNow();
|
||||
expect(serverContents(server)).toEqual(['Start', 'Einmal']);
|
||||
expect(server.log.filter((e) => e.effect.kind === 'insert' && e.effect.blockId === 'b2')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('two clients converge, and a conflict keeps both texts', async () => {
|
||||
const server = new FakeServer();
|
||||
const a = await startEngine(new FakeNet(server));
|
||||
const b = await startEngine(new FakeNet(server));
|
||||
|
||||
edit(a, (blocks) => [{ ...blocks[0], content: 'Von A' }]);
|
||||
edit(b, (blocks) => [{ ...blocks[0], content: 'Von B' }]);
|
||||
await a.syncNow();
|
||||
await b.syncNow(); // B's base version is stale -> conflict copy
|
||||
await a.syncNow();
|
||||
|
||||
expect(contents(a)).toEqual(contents(b));
|
||||
expect(contents(a)).toEqual(['Von A', 'Von B']);
|
||||
expect(flatOf(a)[1].conflictOf).toBe('b1');
|
||||
expect(b.getState().conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps text when two clients create the same first block of an empty note', async () => {
|
||||
const server = new FakeServer([]);
|
||||
const a = await startEngine(new FakeNet(server));
|
||||
const b = await startEngine(new FakeNet(server));
|
||||
a.applyFlat([{ id: 'doc-b0', type: 'text', content: 'Text von A', level: 0 }]);
|
||||
b.applyFlat([{ id: 'doc-b0', type: 'text', content: 'Text von B', level: 0 }]);
|
||||
await a.syncNow();
|
||||
await b.syncNow(); // insert rejected: block exists
|
||||
await b.syncNow(); // text resent as set_content -> conflict copy
|
||||
await a.syncNow();
|
||||
expect(contents(a)).toEqual(contents(b));
|
||||
expect(contents(a)).toEqual(expect.arrayContaining(['Text von A', 'Text von B']));
|
||||
});
|
||||
|
||||
it('does not overwrite a change that arrived while the user kept typing', async () => {
|
||||
const server = new FakeServer([
|
||||
{ id: 'b1', content: 'Start' },
|
||||
{ id: 'b2', content: 'Anderer Punkt' },
|
||||
]);
|
||||
const netA = new FakeNet(server);
|
||||
const a = await startEngine(netA);
|
||||
const b = await startEngine(new FakeNet(server));
|
||||
|
||||
edit(a, (blocks) => [blocks[0], { ...blocks[1], content: 'Anderer Punkt!' }]);
|
||||
netA.duringRequest = () => {
|
||||
// While A's request travels: B's change of b1 reaches the server first, and A starts typing into
|
||||
// b1 (still seeing 'Start')
|
||||
server.apply({ opId: 'b-op', kind: 'set_content', blockId: 'b1', baseVersion: 1, content: 'B war schneller' }, 'cB');
|
||||
edit(a, (blocks) => [{ ...blocks[0], content: 'Start + A' }, blocks[1]]);
|
||||
};
|
||||
await a.syncNow(); // response brings B's change; A's text for b1 is still pending
|
||||
await a.syncNow();
|
||||
await b.syncNow();
|
||||
|
||||
expect(contents(a)).toEqual(contents(b));
|
||||
expect(contents(a)).toEqual(expect.arrayContaining(['B war schneller', 'Start + A', 'Anderer Punkt!']));
|
||||
});
|
||||
|
||||
it('a stale focused text field cannot overwrite a newer remote version', async () => {
|
||||
const server = new FakeServer();
|
||||
const a = await startEngine(new FakeNet(server));
|
||||
const b = await startEngine(new FakeNet(server));
|
||||
|
||||
a.focusBlock('b1'); // A's cursor stays in the block; its field keeps showing 'Start'
|
||||
edit(b, (blocks) => [{ ...blocks[0], content: 'B hat geändert' }]);
|
||||
await b.syncNow();
|
||||
await a.syncNow(); // A receives B's version, but the focused field still shows the old text
|
||||
|
||||
a.applyFlat([{ id: 'b1', type: 'bullet', content: 'Start + A', level: 0 }]);
|
||||
await a.syncNow();
|
||||
await b.syncNow();
|
||||
|
||||
expect(contents(a)).toEqual(contents(b));
|
||||
expect(contents(a)).toEqual(expect.arrayContaining(['B hat geändert', 'Start + A']));
|
||||
});
|
||||
|
||||
it('viewers cannot change anything', async () => {
|
||||
const server = new FakeServer();
|
||||
const viewer = await startEngine(new FakeNet(server, 'viewer'));
|
||||
edit(viewer, (b) => [{ ...b[0], content: 'Leser' }]);
|
||||
await viewer.syncNow();
|
||||
expect(viewer.getState().pendingCount).toBe(0);
|
||||
expect(serverContents(server)).toEqual(['Start']);
|
||||
});
|
||||
|
||||
it('formBatch keeps the last text per block with the base of the first', () => {
|
||||
const batch = formBatch([
|
||||
{ opId: '1', kind: 'set_content', blockId: 'a', content: 'x', baseVersion: 5 },
|
||||
{ opId: '2', kind: 'set_title', title: 'T1', baseVersion: 3 },
|
||||
{ opId: '3', kind: 'set_content', blockId: 'a', content: 'xy', baseVersion: 6 },
|
||||
{ opId: '4', kind: 'set_title', title: 'T2', baseVersion: 4 },
|
||||
]);
|
||||
expect(batch).toEqual([
|
||||
{ opId: '3', kind: 'set_content', blockId: 'a', content: 'xy', baseVersion: 5 },
|
||||
{ opId: '4', kind: 'set_title', title: 'T2', baseVersion: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores state in IndexedDB', async () => {
|
||||
const storage = new IndexedDBStorage(indexedDB);
|
||||
await storage.save({ docId: 'd', title: 'T', titleVersion: 1, lastSeq: 7, role: 'owner', blocks: [], pending: [], inflight: null, savedAt: 1 });
|
||||
const loaded = await storage.load('d');
|
||||
expect(loaded?.lastSeq).toBe(7);
|
||||
expect(await storage.load('missing')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { keyBetween, keysBetween, validOrderKey } from '../orderKey';
|
||||
|
||||
// Generated with internal/collab (Go); both implementations must agree exactly.
|
||||
const VECTORS: [string, string, string][] = [
|
||||
['', '', 'a0'],
|
||||
['a0', '', 'a1'],
|
||||
['', 'a0', 'Zz'],
|
||||
['a0', 'a1', 'a0V'],
|
||||
['a0', 'a0V', 'a0F'],
|
||||
['a0V', 'a1', 'a0k'],
|
||||
['Zz', 'a0', 'ZzV'],
|
||||
['az', '', 'b00'],
|
||||
['', 'Zz', 'Zy'],
|
||||
['a1', 'a1V', 'a1F'],
|
||||
['b00', 'b01', 'b00V'],
|
||||
];
|
||||
|
||||
describe('order keys', () => {
|
||||
it('match the Go implementation', () => {
|
||||
for (const [a, b, want] of VECTORS) expect(keyBetween(a, b)).toBe(want);
|
||||
expect(keysBetween('a0', 'a1', 5)).toEqual(['a07', 'a0F', 'a0V', 'a0c', 'a0k']);
|
||||
let last = '';
|
||||
for (let i = 0; i < 70; i++) last = keyBetween(last, '');
|
||||
expect(last).toBe('b07');
|
||||
});
|
||||
|
||||
it('rejects invalid ranges and keys', () => {
|
||||
expect(() => keyBetween('zz', '')).toThrow();
|
||||
expect(() => keyBetween('a1', 'a0')).toThrow();
|
||||
expect(validOrderKey('a0V0')).toBe(false);
|
||||
});
|
||||
|
||||
it('stays sorted under random inserts', () => {
|
||||
const keys: string[] = [];
|
||||
let seed = 7;
|
||||
const rnd = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const pos = Math.floor(rnd() * (keys.length + 1));
|
||||
keys.splice(pos, 0, keyBetween(keys[pos - 1] || '', keys[pos] || ''));
|
||||
}
|
||||
const sorted = [...keys].sort();
|
||||
expect(keys).toEqual(sorted);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user