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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// Turns the flat block list the editor works with (level + order) into operations against the tree.
|
||||
// Same algorithm as legacyOps in internal/storage/docsync.go.
|
||||
import type { DocumentBlock } from '../types';
|
||||
import { keysBetween } from './orderKey';
|
||||
import { Doc, Op, flatten, normProps, propsEqual } from './model';
|
||||
|
||||
/** Parent index (-1 = top level) for every entry of a flat list; impossible levels are clamped. */
|
||||
export function parentsFromLevels(levels: number[]): number[] {
|
||||
const parents: number[] = [];
|
||||
const stack: number[] = [];
|
||||
levels.forEach((raw, i) => {
|
||||
let lvl = Math.max(0, raw || 0);
|
||||
if (lvl > stack.length) lvl = stack.length;
|
||||
stack.length = lvl;
|
||||
parents.push(lvl > 0 ? stack[lvl - 1] : -1);
|
||||
stack.push(i);
|
||||
});
|
||||
return parents;
|
||||
}
|
||||
|
||||
/** Marks a longest strictly increasing subsequence of the non-empty keys. */
|
||||
export function longestIncreasing(keys: string[]): boolean[] {
|
||||
const keep = new Array(keys.length).fill(false);
|
||||
const tails: number[] = [];
|
||||
const prev = new Array(keys.length).fill(-1);
|
||||
keys.forEach((k, i) => {
|
||||
if (!k) return;
|
||||
let lo = 0;
|
||||
let hi = tails.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (keys[tails[mid]] < k) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo > 0) prev[i] = tails[lo - 1];
|
||||
if (lo === tails.length) tails.push(i);
|
||||
else tails[lo] = i;
|
||||
});
|
||||
if (tails.length > 0) {
|
||||
for (let i = tails[tails.length - 1]; i >= 0; i = prev[i]) keep[i] = true;
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the operations that turn the tree into the given flat list. newOpId creates unique op IDs.
|
||||
* Blocks that keep their place keep their order key, so only real changes become operations.
|
||||
*/
|
||||
export function diffFlat(d: Doc, blocks: DocumentBlock[], newOpId: () => string): Op[] {
|
||||
const list = blocks.map((b) => ({ ...b, type: b.type || 'bullet' }));
|
||||
const parentIdx = parentsFromLevels(list.map((b) => b.level || 0));
|
||||
const parentOf = list.map((_, i) => (parentIdx[i] >= 0 ? list[parentIdx[i]].id : ''));
|
||||
|
||||
const byParent = new Map<string, number[]>();
|
||||
list.forEach((_, i) => {
|
||||
const p = parentOf[i];
|
||||
const arr = byParent.get(p);
|
||||
if (arr) arr.push(i);
|
||||
else byParent.set(p, [i]);
|
||||
});
|
||||
|
||||
const keys: string[] = new Array(list.length);
|
||||
byParent.forEach((idxs, parent) => {
|
||||
const existing = idxs.map((i) => {
|
||||
const b = d.blocks.get(list[i].id);
|
||||
return b && !b.deleted && b.parentId === parent ? b.orderKey : '';
|
||||
});
|
||||
const keep = longestIncreasing(existing);
|
||||
let j = 0;
|
||||
while (j < idxs.length) {
|
||||
if (keep[j]) {
|
||||
keys[idxs[j]] = existing[j];
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
const start = j;
|
||||
while (j < idxs.length && !keep[j]) j++;
|
||||
const left = start > 0 ? keys[idxs[start - 1]] : '';
|
||||
const right = j < idxs.length ? existing[j] : '';
|
||||
let fresh: string[];
|
||||
try {
|
||||
fresh = keysBetween(left, right, j - start);
|
||||
} catch {
|
||||
fresh = keysBetween(left, '', j - start);
|
||||
}
|
||||
for (let k = start; k < j; k++) keys[idxs[k]] = fresh[k - start];
|
||||
}
|
||||
});
|
||||
|
||||
const ops: Op[] = [];
|
||||
const push = (op: Omit<Op, 'opId'>) => ops.push({ opId: newOpId(), ...op } as Op);
|
||||
const wanted = new Set<string>();
|
||||
|
||||
list.forEach((b, i) => {
|
||||
wanted.add(b.id);
|
||||
const parent = parentOf[i];
|
||||
const props = normProps({ checked: b.checked, collapsed: b.collapsed, lang: b.lang });
|
||||
const cur = d.blocks.get(b.id);
|
||||
if (!cur) {
|
||||
push({ kind: 'insert', blockId: b.id, parentId: parent, orderKey: keys[i], type: b.type, content: b.content || '', props });
|
||||
return;
|
||||
}
|
||||
if (cur.deleted) push({ kind: 'restore', ids: [b.id] });
|
||||
if (cur.deleted || cur.parentId !== parent || cur.orderKey !== keys[i]) {
|
||||
push({ kind: 'move', blockId: b.id, parentId: parent, orderKey: keys[i] });
|
||||
}
|
||||
if (cur.content !== (b.content || '')) {
|
||||
push({ kind: 'set_content', blockId: b.id, baseVersion: cur.version, content: b.content || '' });
|
||||
}
|
||||
const curProps = normProps({ checked: cur.props.checked, collapsed: cur.props.collapsed, lang: cur.props.lang });
|
||||
if (cur.type !== b.type || !propsEqual(curProps, props)) {
|
||||
push({ kind: 'set_props', blockId: b.id, patch: { type: b.type, checked: !!b.checked, collapsed: !!b.collapsed, lang: b.lang || '' } });
|
||||
}
|
||||
});
|
||||
|
||||
// Deletes last, after moves have taken wanted children out of deleted branches
|
||||
for (const fb of flatten(d)) {
|
||||
if (!wanted.has(fb.id)) push({ kind: 'delete', blockId: fb.id });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Converts the tree into the flat list used by the editor components. */
|
||||
export function toDocumentBlocks(d: Doc, docId: string, taskLinks?: Map<string, string>): DocumentBlock[] {
|
||||
return flatten(d).map((b, i) => ({
|
||||
id: b.id,
|
||||
documentId: docId,
|
||||
type: b.type as DocumentBlock['type'],
|
||||
content: b.content,
|
||||
checked: !!b.props.checked,
|
||||
collapsed: !!b.props.collapsed,
|
||||
lang: b.props.lang || undefined,
|
||||
level: b.level,
|
||||
position: (i + 1) * 1000,
|
||||
taskId: taskLinks?.get(b.id),
|
||||
conflictOf: b.props.conflictOf,
|
||||
conflictAuthor: b.props.conflictAuthor,
|
||||
}));
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Block tree, operations and effects of collaborative note editing. Port of internal/collab (doc.go, ops.go).
|
||||
// applyEffect must behave exactly like the server's Doc.ApplyEffect, because clients replay the server log.
|
||||
import { keyBetween, validOrderKey } from './orderKey';
|
||||
|
||||
export interface Props {
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
lang?: string;
|
||||
conflictOf?: string;
|
||||
conflictAuthor?: string;
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
parentId: string; // '' = top level
|
||||
orderKey: string;
|
||||
type: string;
|
||||
content: string;
|
||||
props: Props;
|
||||
version: number;
|
||||
deleted?: boolean;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface Doc {
|
||||
title: string;
|
||||
titleVersion: number;
|
||||
blocks: Map<string, Block>;
|
||||
}
|
||||
|
||||
export interface PropsPatch {
|
||||
type?: string;
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export type OpKind = 'insert' | 'set_content' | 'set_props' | 'move' | 'delete' | 'restore' | 'set_title';
|
||||
|
||||
export interface Op {
|
||||
opId: string;
|
||||
kind: OpKind;
|
||||
blockId?: string;
|
||||
baseVersion?: number;
|
||||
parentId?: string;
|
||||
orderKey?: string;
|
||||
type?: string;
|
||||
content?: string;
|
||||
props?: Props;
|
||||
patch?: PropsPatch;
|
||||
ids?: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface Effect {
|
||||
kind: OpKind;
|
||||
blockId?: string;
|
||||
parentId?: string;
|
||||
orderKey?: string;
|
||||
type?: string;
|
||||
content?: string;
|
||||
props?: Props;
|
||||
version?: number;
|
||||
ids?: string[];
|
||||
title?: string;
|
||||
titleVersion?: number;
|
||||
by?: string;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
opId: string;
|
||||
status: 'applied' | 'conflict' | 'rejected';
|
||||
reason?: string;
|
||||
version?: number;
|
||||
conflictBlockId?: string;
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
export interface FlatBlock extends Block {
|
||||
level: number;
|
||||
}
|
||||
|
||||
export function emptyDoc(): Doc {
|
||||
return { title: '', titleVersion: 0, blocks: new Map() };
|
||||
}
|
||||
|
||||
export function cloneDoc(d: Doc): Doc {
|
||||
const blocks = new Map<string, Block>();
|
||||
d.blocks.forEach((b, id) => blocks.set(id, { ...b, props: { ...b.props } }));
|
||||
return { title: d.title, titleVersion: d.titleVersion, blocks };
|
||||
}
|
||||
|
||||
/** Normalizes props so that missing and false/empty values compare equal (the server omits them in JSON). */
|
||||
export function normProps(p?: Props): Props {
|
||||
const out: Props = {};
|
||||
if (p?.checked) out.checked = true;
|
||||
if (p?.collapsed) out.collapsed = true;
|
||||
if (p?.lang) out.lang = p.lang;
|
||||
if (p?.conflictOf) out.conflictOf = p.conflictOf;
|
||||
if (p?.conflictAuthor) out.conflictAuthor = p.conflictAuthor;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function propsEqual(a?: Props, b?: Props): boolean {
|
||||
const x = normProps(a);
|
||||
const y = normProps(b);
|
||||
return (
|
||||
x.checked === y.checked &&
|
||||
x.collapsed === y.collapsed &&
|
||||
x.lang === y.lang &&
|
||||
x.conflictOf === y.conflictOf &&
|
||||
x.conflictAuthor === y.conflictAuthor
|
||||
);
|
||||
}
|
||||
|
||||
function siblingLess(a: Block, b: Block): number {
|
||||
if (a.orderKey !== b.orderKey) return a.orderKey < b.orderKey ? -1 : 1;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Live children of parentId in display order. */
|
||||
export function children(d: Doc, parentId: string): Block[] {
|
||||
const out: Block[] = [];
|
||||
d.blocks.forEach((b) => {
|
||||
if (!b.deleted && b.parentId === parentId) out.push(b);
|
||||
});
|
||||
return out.sort(siblingLess);
|
||||
}
|
||||
|
||||
/** All live blocks in display order with their nesting level. */
|
||||
export function flatten(d: Doc): FlatBlock[] {
|
||||
const byParent = new Map<string, Block[]>();
|
||||
d.blocks.forEach((b) => {
|
||||
if (b.deleted) return;
|
||||
const list = byParent.get(b.parentId);
|
||||
if (list) list.push(b);
|
||||
else byParent.set(b.parentId, [b]);
|
||||
});
|
||||
byParent.forEach((list) => list.sort(siblingLess));
|
||||
const out: FlatBlock[] = [];
|
||||
const walk = (parent: string, level: number) => {
|
||||
for (const b of byParent.get(parent) || []) {
|
||||
out.push({ ...b, level });
|
||||
walk(b.id, level + 1);
|
||||
}
|
||||
};
|
||||
walk('', 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
function subtree(d: Doc, id: string): string[] {
|
||||
const kids = new Map<string, string[]>();
|
||||
d.blocks.forEach((b) => {
|
||||
if (b.deleted) return;
|
||||
const list = kids.get(b.parentId);
|
||||
if (list) list.push(b.id);
|
||||
else kids.set(b.parentId, [b.id]);
|
||||
});
|
||||
const out = [id];
|
||||
for (let i = 0; i < out.length; i++) out.push(...(kids.get(out[i]) || []));
|
||||
return [out[0], ...out.slice(1).sort()];
|
||||
}
|
||||
|
||||
function liveAncestor(d: Doc, id: string): string {
|
||||
const seen = new Set<string>();
|
||||
while (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
const b = d.blocks.get(id);
|
||||
if (!b) return '';
|
||||
if (!b.deleted) return id;
|
||||
id = b.parentId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isAncestor(d: Doc, ancestorId: string, id: string): boolean {
|
||||
const seen = new Set<string>();
|
||||
while (id && !seen.has(id)) {
|
||||
if (id === ancestorId) return true;
|
||||
seen.add(id);
|
||||
const b = d.blocks.get(id);
|
||||
if (!b) return false;
|
||||
id = b.parentId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Mutates the document with one effect, exactly like the server. */
|
||||
export function applyEffect(d: Doc, e: Effect): void {
|
||||
switch (e.kind) {
|
||||
case 'insert':
|
||||
d.blocks.set(e.blockId!, {
|
||||
id: e.blockId!,
|
||||
parentId: e.parentId || '',
|
||||
orderKey: e.orderKey || '',
|
||||
type: e.type || '',
|
||||
content: e.content || '',
|
||||
props: normProps(e.props),
|
||||
version: e.version || 0,
|
||||
deleted: false,
|
||||
updatedBy: e.by || '',
|
||||
});
|
||||
break;
|
||||
case 'set_content': {
|
||||
const b = d.blocks.get(e.blockId!);
|
||||
if (b) {
|
||||
b.content = e.content || '';
|
||||
b.version = e.version || 0;
|
||||
b.updatedBy = e.by || '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'set_props': {
|
||||
const b = d.blocks.get(e.blockId!);
|
||||
if (b) {
|
||||
b.type = e.type || '';
|
||||
if (e.props) b.props = normProps(e.props);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'move': {
|
||||
const b = d.blocks.get(e.blockId!);
|
||||
if (b) {
|
||||
b.parentId = e.parentId || '';
|
||||
b.orderKey = e.orderKey || '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete':
|
||||
for (const id of e.ids || []) {
|
||||
const b = d.blocks.get(id);
|
||||
if (b) b.deleted = true;
|
||||
}
|
||||
break;
|
||||
case 'restore':
|
||||
for (const id of e.ids || []) {
|
||||
const b = d.blocks.get(id);
|
||||
if (b) b.deleted = false;
|
||||
}
|
||||
break;
|
||||
case 'set_title':
|
||||
d.title = e.title || '';
|
||||
d.titleVersion = e.titleVersion || 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an operation the way the server does (without leases). The client uses it to show its own
|
||||
* unconfirmed operations on top of the confirmed state; tests use it as a stand-in server.
|
||||
* conflictId derives the ID of a conflict copy (the server uses a hash of the op ID).
|
||||
*/
|
||||
export function applyOp(d: Doc, op: Op, by = '', conflictId: (opId: string) => string = (id) => `blk_cf_${id}`): { effects: Effect[]; result: Result } {
|
||||
const effects: Effect[] = [];
|
||||
const emit = (e: Effect) => {
|
||||
e.by = by;
|
||||
applyEffect(d, e);
|
||||
effects.push(e);
|
||||
};
|
||||
const done = (r: Omit<Result, 'opId'>) => ({ effects, result: { opId: op.opId, ...r } as Result });
|
||||
const reject = (reason: string) => done({ status: 'rejected', reason });
|
||||
|
||||
const restore = (ids: string[]) => {
|
||||
const set = new Set(ids);
|
||||
const sorted = [...ids].sort();
|
||||
emit({ kind: 'restore', ids: sorted });
|
||||
for (const id of sorted) {
|
||||
const b = d.blocks.get(id)!;
|
||||
if (b.parentId === '' || set.has(b.parentId)) continue;
|
||||
const p = d.blocks.get(b.parentId);
|
||||
if (p && !p.deleted) continue;
|
||||
emit({ kind: 'move', blockId: b.id, parentId: liveAncestor(d, b.parentId), orderKey: b.orderKey });
|
||||
}
|
||||
};
|
||||
const keyAfter = (b: Block): string => {
|
||||
const sibs = children(d, b.parentId);
|
||||
let next = '';
|
||||
for (const s of sibs) {
|
||||
if (s.orderKey > b.orderKey) {
|
||||
next = s.orderKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return keyBetween(b.orderKey, next);
|
||||
} catch {
|
||||
return keyBetween(b.orderKey, '');
|
||||
}
|
||||
};
|
||||
|
||||
switch (op.kind) {
|
||||
case 'insert': {
|
||||
if (!op.blockId || op.blockId.length > 64) return reject('invalid_id');
|
||||
if (d.blocks.has(op.blockId)) return reject('exists');
|
||||
let parent = op.parentId || '';
|
||||
if (parent) {
|
||||
if (!d.blocks.has(parent)) return reject('parent_missing');
|
||||
parent = liveAncestor(d, parent);
|
||||
}
|
||||
if (!op.orderKey || !validOrderKey(op.orderKey)) return reject('invalid_order_key');
|
||||
emit({
|
||||
kind: 'insert',
|
||||
blockId: op.blockId,
|
||||
parentId: parent,
|
||||
orderKey: op.orderKey,
|
||||
type: op.type || 'bullet',
|
||||
content: op.content || '',
|
||||
props: normProps(op.props),
|
||||
version: 1,
|
||||
});
|
||||
return done({ status: 'applied', version: 1 });
|
||||
}
|
||||
case 'set_content': {
|
||||
const b = d.blocks.get(op.blockId || '');
|
||||
if (!b) return reject('missing');
|
||||
if (op.content === undefined) return reject('no_content');
|
||||
if (b.deleted) restore([b.id]);
|
||||
if (op.content === b.content) return done({ status: 'applied', version: b.version });
|
||||
if ((op.baseVersion ?? 0) !== b.version) {
|
||||
const id = conflictId(op.opId);
|
||||
if (!d.blocks.has(id)) {
|
||||
emit({
|
||||
kind: 'insert',
|
||||
blockId: id,
|
||||
parentId: b.parentId,
|
||||
orderKey: keyAfter(b),
|
||||
type: b.type,
|
||||
content: op.content,
|
||||
props: { conflictOf: b.id },
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
return done({ status: 'conflict', version: b.version, conflictBlockId: id });
|
||||
}
|
||||
emit({ kind: 'set_content', blockId: b.id, content: op.content, version: b.version + 1 });
|
||||
return done({ status: 'applied', version: b.version });
|
||||
}
|
||||
case 'set_props': {
|
||||
const b = d.blocks.get(op.blockId || '');
|
||||
if (!b || b.deleted) return reject('missing');
|
||||
if (!op.patch) return reject('no_patch');
|
||||
const type = op.patch.type ? op.patch.type : b.type;
|
||||
const props = normProps({
|
||||
...b.props,
|
||||
...(op.patch.checked !== undefined ? { checked: op.patch.checked } : {}),
|
||||
...(op.patch.collapsed !== undefined ? { collapsed: op.patch.collapsed } : {}),
|
||||
...(op.patch.lang !== undefined ? { lang: op.patch.lang } : {}),
|
||||
});
|
||||
if (type !== b.type || !propsEqual(props, b.props)) emit({ kind: 'set_props', blockId: b.id, type, props });
|
||||
return done({ status: 'applied' });
|
||||
}
|
||||
case 'move': {
|
||||
const b = d.blocks.get(op.blockId || '');
|
||||
if (!b || b.deleted) return reject('missing');
|
||||
const parent = op.parentId || '';
|
||||
if (parent) {
|
||||
const p = d.blocks.get(parent);
|
||||
if (!p || p.deleted) return reject('parent_missing');
|
||||
if (isAncestor(d, b.id, parent)) return reject('cycle');
|
||||
}
|
||||
if (!op.orderKey || !validOrderKey(op.orderKey)) return reject('invalid_order_key');
|
||||
if (parent !== b.parentId || op.orderKey !== b.orderKey) emit({ kind: 'move', blockId: b.id, parentId: parent, orderKey: op.orderKey });
|
||||
return done({ status: 'applied' });
|
||||
}
|
||||
case 'delete': {
|
||||
const b = d.blocks.get(op.blockId || '');
|
||||
if (!b) return reject('missing');
|
||||
if (!b.deleted) emit({ kind: 'delete', ids: subtree(d, b.id) });
|
||||
return done({ status: 'applied' });
|
||||
}
|
||||
case 'restore': {
|
||||
const ids = (op.ids || []).filter((id) => d.blocks.get(id)?.deleted);
|
||||
if (ids.length > 0) restore(ids);
|
||||
return done({ status: 'applied' });
|
||||
}
|
||||
case 'set_title': {
|
||||
if (op.title === undefined) return reject('no_title');
|
||||
if (op.title === d.title) return done({ status: 'applied', version: d.titleVersion });
|
||||
if ((op.baseVersion ?? 0) !== d.titleVersion) return done({ status: 'conflict', version: d.titleVersion });
|
||||
emit({ kind: 'set_title', title: op.title, titleVersion: d.titleVersion + 1 });
|
||||
return done({ status: 'applied', version: d.titleVersion });
|
||||
}
|
||||
}
|
||||
return reject('unknown_kind');
|
||||
}
|
||||
|
||||
/** Builds a Doc from snapshot blocks (as delivered by GET /docs/{id}/snapshot). */
|
||||
export function docFromSnapshot(title: string, titleVersion: number, blocks: Block[]): Doc {
|
||||
const d: Doc = { title, titleVersion, blocks: new Map() };
|
||||
for (const b of blocks) {
|
||||
d.blocks.set(b.id, {
|
||||
id: b.id,
|
||||
parentId: b.parentId || '',
|
||||
orderKey: b.orderKey,
|
||||
type: b.type || '',
|
||||
content: b.content || '',
|
||||
props: normProps(b.props),
|
||||
version: b.version || 0,
|
||||
deleted: !!b.deleted,
|
||||
updatedBy: b.updatedBy || '',
|
||||
});
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export function docsEqual(a: Doc, b: Doc): boolean {
|
||||
if (a.title !== b.title || a.titleVersion !== b.titleVersion || a.blocks.size !== b.blocks.size) return false;
|
||||
for (const [id, x] of a.blocks) {
|
||||
const y = b.blocks.get(id);
|
||||
if (
|
||||
!y ||
|
||||
x.parentId !== y.parentId ||
|
||||
x.orderKey !== y.orderKey ||
|
||||
x.type !== y.type ||
|
||||
x.content !== y.content ||
|
||||
x.version !== y.version ||
|
||||
!!x.deleted !== !!y.deleted ||
|
||||
!propsEqual(x.props, y.props)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Order keys sort siblings (ties broken by block ID). Port of internal/collab/orderkey.go; both sides must
|
||||
// produce identical keys, see orderKey.test.ts for shared test vectors.
|
||||
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
const SMALLEST_INTEGER = 'A00000000000000000000000000';
|
||||
|
||||
function digitIndex(c: string): number {
|
||||
return DIGITS.indexOf(c);
|
||||
}
|
||||
|
||||
function integerLength(head: string): number {
|
||||
if (head >= 'a' && head <= 'z') return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2;
|
||||
if (head >= 'A' && head <= 'Z') return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function integerPart(key: string): string | null {
|
||||
if (!key) return null;
|
||||
const n = integerLength(key[0]);
|
||||
if (n === 0 || n > key.length) return null;
|
||||
return key.slice(0, n);
|
||||
}
|
||||
|
||||
export function validOrderKey(k: string): boolean {
|
||||
if (!k || k.length > 256 || k === SMALLEST_INTEGER) return false;
|
||||
const ip = integerPart(k);
|
||||
if (ip === null) return false;
|
||||
for (let i = 1; i < k.length; i++) {
|
||||
if (digitIndex(k[i]) < 0) return false;
|
||||
}
|
||||
const frac = k.slice(ip.length);
|
||||
return frac === '' || frac[frac.length - 1] !== '0';
|
||||
}
|
||||
|
||||
function incrementInteger(x: string): string | null {
|
||||
const head = x[0];
|
||||
const digs = x.slice(1).split('');
|
||||
let carry = true;
|
||||
for (let i = digs.length - 1; carry && i >= 0; i--) {
|
||||
const d = digitIndex(digs[i]) + 1;
|
||||
if (d === DIGITS.length) {
|
||||
digs[i] = '0';
|
||||
} else {
|
||||
digs[i] = DIGITS[d];
|
||||
carry = false;
|
||||
}
|
||||
}
|
||||
if (!carry) return head + digs.join('');
|
||||
if (head === 'Z') return 'a0';
|
||||
if (head === 'z') return null;
|
||||
const h = String.fromCharCode(head.charCodeAt(0) + 1);
|
||||
if (h > 'a') digs.push('0');
|
||||
else digs.pop();
|
||||
return h + digs.join('');
|
||||
}
|
||||
|
||||
function decrementInteger(x: string): string | null {
|
||||
const head = x[0];
|
||||
const digs = x.slice(1).split('');
|
||||
let borrow = true;
|
||||
for (let i = digs.length - 1; borrow && i >= 0; i--) {
|
||||
const d = digitIndex(digs[i]) - 1;
|
||||
if (d === -1) {
|
||||
digs[i] = 'z';
|
||||
} else {
|
||||
digs[i] = DIGITS[d];
|
||||
borrow = false;
|
||||
}
|
||||
}
|
||||
if (!borrow) return head + digs.join('');
|
||||
if (head === 'a') return 'Z' + DIGITS[DIGITS.length - 1];
|
||||
if (head === 'A') return null;
|
||||
const h = String.fromCharCode(head.charCodeAt(0) - 1);
|
||||
if (h < 'Z') digs.push('z');
|
||||
else digs.pop();
|
||||
return h + digs.join('');
|
||||
}
|
||||
|
||||
function midpoint(a: string, b: string): string {
|
||||
if (b !== '') {
|
||||
let n = 0;
|
||||
while (n < b.length) {
|
||||
const ca = n < a.length ? a[n] : '0';
|
||||
if (ca !== b[n]) break;
|
||||
n++;
|
||||
}
|
||||
if (n > 0) return b.slice(0, n) + midpoint(n < a.length ? a.slice(n) : '', b.slice(n));
|
||||
}
|
||||
const da = a !== '' ? digitIndex(a[0]) : 0;
|
||||
const db = b !== '' ? digitIndex(b[0]) : DIGITS.length;
|
||||
if (db - da > 1) return DIGITS[Math.floor((da + db) / 2)];
|
||||
if (b !== '' && b.length > 1) return b.slice(0, 1);
|
||||
return DIGITS[da] + midpoint(a.length > 1 ? a.slice(1) : '', '');
|
||||
}
|
||||
|
||||
/** Returns a key strictly between a and b; '' means open end. Throws on an invalid range. */
|
||||
export function keyBetween(a: string, b: string): string {
|
||||
if ((a !== '' && !validOrderKey(a)) || (b !== '' && !validOrderKey(b)) || (a !== '' && b !== '' && a >= b)) {
|
||||
throw new Error(`invalid order key range ${a}..${b}`);
|
||||
}
|
||||
if (a === '' && b === '') return 'a0';
|
||||
if (a === '') {
|
||||
const ib = integerPart(b)!;
|
||||
const fb = b.slice(ib.length);
|
||||
if (ib === SMALLEST_INTEGER) return ib + midpoint('', fb);
|
||||
if (ib < b) return ib;
|
||||
const res = decrementInteger(ib);
|
||||
if (res === null) throw new Error('order key range exhausted');
|
||||
return res;
|
||||
}
|
||||
if (b === '') {
|
||||
const ia = integerPart(a)!;
|
||||
const i = incrementInteger(ia);
|
||||
return i !== null ? i : ia + midpoint(a.slice(ia.length), '');
|
||||
}
|
||||
const ia = integerPart(a)!;
|
||||
const ib = integerPart(b)!;
|
||||
if (ia === ib) return ia + midpoint(a.slice(ia.length), b.slice(ib.length));
|
||||
const i = incrementInteger(ia);
|
||||
if (i === null) throw new Error('order key range exhausted');
|
||||
return i < b ? i : ia + midpoint(a.slice(ia.length), '');
|
||||
}
|
||||
|
||||
/** Returns n increasing keys between a and b, spread out so bulk inserts stay short. */
|
||||
export function keysBetween(a: string, b: string, n: number): string[] {
|
||||
if (n <= 0) return [];
|
||||
if (n === 1) return [keyBetween(a, b)];
|
||||
if (b === '') {
|
||||
const out: string[] = [];
|
||||
let prev = a;
|
||||
for (let i = 0; i < n; i++) {
|
||||
prev = keyBetween(prev, '');
|
||||
out.push(prev);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (a === '') {
|
||||
const out: string[] = new Array(n);
|
||||
let next = b;
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
next = keyBetween('', next);
|
||||
out[i] = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const mid = Math.floor(n / 2);
|
||||
const c = keyBetween(a, b);
|
||||
return [...keysBetween(a, c, mid), c, ...keysBetween(c, b, n - mid - 1)];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Persists the sync state of each note in IndexedDB so that typed text survives reloads, crashes and
|
||||
// offline phases. Falls back to memory when IndexedDB is unavailable (private mode, old browsers).
|
||||
import type { Block, Op } from './model';
|
||||
|
||||
export interface StoredDocState {
|
||||
docId: string;
|
||||
title: string;
|
||||
titleVersion: number;
|
||||
lastSeq: number;
|
||||
role: string;
|
||||
blocks: Block[];
|
||||
pending: Op[];
|
||||
inflight: Op[] | null;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export interface SyncStorage {
|
||||
load(docId: string): Promise<StoredDocState | null>;
|
||||
save(state: StoredDocState): Promise<void>;
|
||||
}
|
||||
|
||||
const DB_NAME = 'mindsift-sync';
|
||||
const STORE = 'docs';
|
||||
|
||||
export class MemoryStorage implements SyncStorage {
|
||||
private data = new Map<string, StoredDocState>();
|
||||
async load(docId: string) {
|
||||
const s = this.data.get(docId);
|
||||
return s ? JSON.parse(JSON.stringify(s)) : null;
|
||||
}
|
||||
async save(state: StoredDocState) {
|
||||
this.data.set(state.docId, JSON.parse(JSON.stringify(state)));
|
||||
}
|
||||
}
|
||||
|
||||
export class IndexedDBStorage implements SyncStorage {
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
constructor(private factory: IDBFactory = indexedDB) {}
|
||||
|
||||
private open(): Promise<IDBDatabase> {
|
||||
if (!this.dbPromise) {
|
||||
this.dbPromise = new Promise((resolve, reject) => {
|
||||
const req = this.factory.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE, { keyPath: 'docId' });
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
this.dbPromise.catch(() => {
|
||||
this.dbPromise = null;
|
||||
});
|
||||
}
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
async load(docId: string): Promise<StoredDocState | null> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(docId);
|
||||
req.onsuccess = () => resolve((req.result as StoredDocState) || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async save(state: StoredDocState): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).put(state);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** IndexedDB when available, otherwise memory (state is then lost on reload). */
|
||||
export function defaultStorage(): SyncStorage {
|
||||
try {
|
||||
if (typeof indexedDB !== 'undefined') return new IndexedDBStorage(indexedDB);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return new MemoryStorage();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// HTTP transport of the note sync protocol (see Doku/api/rest.md). Every data exchange is a plain request,
|
||||
// so it works behind proxies that block WebSockets.
|
||||
import { getToken } from '../api';
|
||||
import type { Block, Effect, Op, Result } from './model';
|
||||
|
||||
export interface LeaseInfo {
|
||||
blockId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Presence {
|
||||
userId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SnapshotResponse {
|
||||
docId: string;
|
||||
title: string;
|
||||
titleVersion: number;
|
||||
headSeq: number;
|
||||
blocks: Block[];
|
||||
role: string;
|
||||
shared: boolean;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
seq: number;
|
||||
opId?: string;
|
||||
userId: string;
|
||||
clientId: string;
|
||||
effect: Effect;
|
||||
}
|
||||
|
||||
export interface SyncRequest {
|
||||
clientId: string;
|
||||
lastSeq: number;
|
||||
ops: Op[];
|
||||
leases?: { hold: string[]; release: string[] };
|
||||
}
|
||||
|
||||
export interface SyncResponse {
|
||||
results: Result[];
|
||||
ops: LogEntry[];
|
||||
headSeq: number;
|
||||
hasMore: boolean;
|
||||
resync?: boolean;
|
||||
role: string;
|
||||
shared: boolean;
|
||||
sessions: number;
|
||||
presence: Presence[];
|
||||
leases: { granted: string[]; denied: string[]; others: LeaseInfo[] };
|
||||
}
|
||||
|
||||
/** status 0 = network error or timeout; otherwise the HTTP status. */
|
||||
export class TransportError extends Error {
|
||||
constructor(message: string, public status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Transport {
|
||||
snapshot(docId: string): Promise<SnapshotResponse>;
|
||||
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> {
|
||||
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);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { ...init, headers, cache: 'no-store', signal: init.keepalive ? undefined : controller.signal });
|
||||
} catch (err: any) {
|
||||
throw new TransportError(err?.message || 'Netzwerkfehler', 0);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (res.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data?.error) msg = data.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new TransportError(msg, res.status);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const httpTransport: Transport = {
|
||||
snapshot: (docId) => call(`/api/v1/docs/${encodeURIComponent(docId)}/snapshot`),
|
||||
sync: (docId, body, opts) =>
|
||||
call(`/api/v1/docs/${encodeURIComponent(docId)}/sync`, { method: 'POST', body: JSON.stringify(body), keepalive: opts?.keepalive }),
|
||||
leave: async (docId, clientId) => {
|
||||
await call(`/api/v1/docs/${encodeURIComponent(docId)}/leave`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ clientId }),
|
||||
keepalive: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user