Supersedes the active-session "defer" guard with a real merge (review #5 — "запись делать через мерж", not skip-while-editing). writeBody no longer does delete-all + re-insert (which discarded a concurrent editor's in-flight changes on every sync). It now diffs the live body against the incoming git body at TOP-LEVEL BLOCK granularity (LCS over a canonical structural serialization) and applies only the minimal inserts/deletes: - a block a human is editing is left UNTOUCHED when git changed a DIFFERENT block; - an unchanged resync is a complete 0-op write; - Yjs CRDT-merges the minimal ops with concurrent edits. New yjs-body-merge.ts (mergeXmlFragments + cloneXmlNode + diffBlocks) is pure-Yjs and unit-tested with real Y.Docs (8 tests): identical->0 ops, edit-one-block keeps the other block instances, append/delete keep neighbours, marks survive the cross-doc clone. Crash-safety kept: the incoming doc is built before the connection opens, so a transform failure can't empty the body. Removed: the ActiveEditSessionError defer path and the now-unused CollaborationGateway.getActiveEditorCount. Honest limitation: this is a 2-way merge — for a block BOTH sides changed since the last sync, git wins (no common ancestor to decide). A full 3-way merge would need the last-synced base plumbed from the engine; the dominant cases (unchanged resync, edits to different blocks) are now lossless. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
import * as Y from 'yjs';
|
|
|
|
import {
|
|
mergeXmlFragments,
|
|
cloneXmlNode,
|
|
diffBlocks,
|
|
} from './yjs-body-merge';
|
|
|
|
// Build a Y.XmlFragment('default') in `doc` from a list of paragraph specs.
|
|
// Each spec is the paragraph's plain text (a single XmlText child).
|
|
function buildFragment(doc: Y.Doc, paragraphs: string[]): Y.XmlFragment {
|
|
const frag = doc.getXmlFragment('default');
|
|
const blocks = paragraphs.map((text) => {
|
|
const el = new Y.XmlElement('paragraph');
|
|
const t = new Y.XmlText();
|
|
if (text) t.insert(0, text);
|
|
el.insert(0, [t]);
|
|
return el;
|
|
});
|
|
if (blocks.length) frag.insert(0, blocks);
|
|
return frag;
|
|
}
|
|
|
|
function texts(frag: Y.XmlFragment): string[] {
|
|
return frag.toArray().map((el) => (el as Y.XmlElement).toArray()
|
|
.map((c) => (c as Y.XmlText).toString())
|
|
.join(''));
|
|
}
|
|
|
|
describe('yjs-body-merge', () => {
|
|
describe('diffBlocks (LCS edit script)', () => {
|
|
it('identical sequences produce only keeps (no edits)', () => {
|
|
const ops = diffBlocks(['a', 'b', 'c'], ['a', 'b', 'c']);
|
|
expect(ops.every((o) => o.op === 'keep')).toBe(true);
|
|
});
|
|
|
|
it('a single changed middle element is one del + one ins', () => {
|
|
const ops = diffBlocks(['a', 'b', 'c'], ['a', 'B', 'c']);
|
|
expect(ops.filter((o) => o.op === 'del')).toHaveLength(1);
|
|
expect(ops.filter((o) => o.op === 'ins')).toHaveLength(1);
|
|
expect(ops.filter((o) => o.op === 'keep')).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('mergeXmlFragments', () => {
|
|
it('identical content is a complete no-op (0 ops) — never clobbers an unchanged resync', () => {
|
|
const live = new Y.Doc();
|
|
const target = new Y.Doc();
|
|
const liveFrag = buildFragment(live, ['one', 'two', 'three']);
|
|
const targetFrag = buildFragment(target, ['one', 'two', 'three']);
|
|
|
|
// Capture block identities to prove they are left untouched.
|
|
const before = liveFrag.toArray();
|
|
let applied = -1;
|
|
live.transact(() => {
|
|
applied = mergeXmlFragments(liveFrag, targetFrag);
|
|
});
|
|
|
|
expect(applied).toBe(0);
|
|
// Same Y.XmlElement instances — nothing was deleted/recreated.
|
|
expect(liveFrag.toArray()).toEqual(before);
|
|
expect(texts(liveFrag)).toEqual(['one', 'two', 'three']);
|
|
});
|
|
|
|
it('a human edit to one block survives a git change to a DIFFERENT block', () => {
|
|
// Live: the human has the doc open; block 0 holds their edit. Git changed
|
|
// only block 2. The merge must touch ONLY block 2 and leave block 0 (and
|
|
// its in-flight edit) exactly as-is.
|
|
const live = new Y.Doc();
|
|
const target = new Y.Doc();
|
|
const liveFrag = buildFragment(live, ['HUMAN EDIT', 'shared', 'old tail']);
|
|
const targetFrag = buildFragment(target, [
|
|
'HUMAN EDIT',
|
|
'shared',
|
|
'new tail from git',
|
|
]);
|
|
|
|
const block0Before = liveFrag.get(0); // the human's block instance
|
|
const block1Before = liveFrag.get(1);
|
|
|
|
let applied = -1;
|
|
live.transact(() => {
|
|
applied = mergeXmlFragments(liveFrag, targetFrag);
|
|
});
|
|
|
|
// Only block 2 was replaced: one del + one ins.
|
|
expect(applied).toBe(2);
|
|
// The human's block and the shared block are the SAME instances (untouched).
|
|
expect(liveFrag.get(0)).toBe(block0Before);
|
|
expect(liveFrag.get(1)).toBe(block1Before);
|
|
// Block 2 now carries git's content.
|
|
expect(texts(liveFrag)).toEqual([
|
|
'HUMAN EDIT',
|
|
'shared',
|
|
'new tail from git',
|
|
]);
|
|
});
|
|
|
|
it('appends a new trailing block without disturbing existing ones', () => {
|
|
const live = new Y.Doc();
|
|
const target = new Y.Doc();
|
|
const liveFrag = buildFragment(live, ['a', 'b']);
|
|
const targetFrag = buildFragment(target, ['a', 'b', 'c']);
|
|
const a = liveFrag.get(0);
|
|
const b = liveFrag.get(1);
|
|
|
|
let applied = -1;
|
|
live.transact(() => {
|
|
applied = mergeXmlFragments(liveFrag, targetFrag);
|
|
});
|
|
|
|
expect(applied).toBe(1); // single insert
|
|
expect(liveFrag.get(0)).toBe(a);
|
|
expect(liveFrag.get(1)).toBe(b);
|
|
expect(texts(liveFrag)).toEqual(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('deletes a removed block, keeping its neighbours', () => {
|
|
const live = new Y.Doc();
|
|
const target = new Y.Doc();
|
|
const liveFrag = buildFragment(live, ['a', 'b', 'c']);
|
|
const targetFrag = buildFragment(target, ['a', 'c']);
|
|
const a = liveFrag.get(0);
|
|
|
|
let applied = -1;
|
|
live.transact(() => {
|
|
applied = mergeXmlFragments(liveFrag, targetFrag);
|
|
});
|
|
|
|
expect(applied).toBe(1); // single delete
|
|
expect(liveFrag.get(0)).toBe(a);
|
|
expect(texts(liveFrag)).toEqual(['a', 'c']);
|
|
});
|
|
|
|
it('a fully different body is replaced (and stays valid)', () => {
|
|
const live = new Y.Doc();
|
|
const target = new Y.Doc();
|
|
const liveFrag = buildFragment(live, ['x', 'y']);
|
|
const targetFrag = buildFragment(target, ['p', 'q', 'r']);
|
|
live.transact(() => mergeXmlFragments(liveFrag, targetFrag));
|
|
expect(texts(liveFrag)).toEqual(['p', 'q', 'r']);
|
|
});
|
|
});
|
|
|
|
describe('cloneXmlNode', () => {
|
|
it('preserves text marks (XmlText delta) across docs', () => {
|
|
const src = new Y.Doc();
|
|
const srcFrag = src.getXmlFragment('default');
|
|
const el = new Y.XmlElement('paragraph');
|
|
const t = new Y.XmlText();
|
|
t.insert(0, 'plain ');
|
|
t.insert(6, 'bold', { bold: true });
|
|
el.insert(0, [t]);
|
|
srcFrag.insert(0, [el]);
|
|
|
|
const dst = new Y.Doc();
|
|
const dstFrag = dst.getXmlFragment('default');
|
|
dstFrag.insert(0, [cloneXmlNode(srcFrag.get(0) as Y.XmlElement)]);
|
|
|
|
const clonedText = (dstFrag.get(0) as Y.XmlElement).get(0) as Y.XmlText;
|
|
expect(clonedText.toDelta()).toEqual([
|
|
{ insert: 'plain ' },
|
|
{ insert: 'bold', attributes: { bold: true } },
|
|
]);
|
|
});
|
|
});
|
|
});
|