// Two people on one document: what Yjs merges, and what the janitor repairs // after a merge (plan ยง5.3). Each case starts from one example in two // replicas, changes them apart, exchanges their updates both ways and looks // at the project they hold. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import { openProjectArchive, type ImportedProject } from '@mdg-filius/fls'; import { readProject, repair, ROOTS, writeProject } from '../src/index.js'; const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); function opened(file: string): ImportedProject { let counter = 0; return openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); } /** Two replicas of the example, alike. */ function replicas(file = 'java-examples/zwei_rechnernetze_komplett.fls'): [Y.Doc, Y.Doc] { const first = new Y.Doc(); writeProject(first, opened(file)); const second = new Y.Doc(); Y.applyUpdate(second, Y.encodeStateAsUpdate(first)); return [first, second]; } /** Each replica gets what the other has. */ function exchange(first: Y.Doc, second: Y.Doc): void { const toSecond = Y.encodeStateAsUpdate(first, Y.encodeStateVector(second)); const toFirst = Y.encodeStateAsUpdate(second, Y.encodeStateVector(first)); Y.applyUpdate(second, toSecond); Y.applyUpdate(first, toFirst); } const nodes = (doc: Y.Doc) => doc.getMap>(ROOTS.nodes); const links = (doc: Y.Doc) => doc.getMap>(ROOTS.links); const nodeNamed = (doc: Y.Doc, name: string) => [...nodes(doc).entries()].find(([, node]) => node.get('name') === name)!; const settings = (doc: Y.Doc, name: string) => nodeNamed(doc, name)[1].get('settings') as Y.Map>; const files = (doc: Y.Doc, name: string) => nodeNamed(doc, name)[1].get('files') as Y.Map>; /** A switch's port no cable uses. */ function freePort(doc: Y.Doc, switchName: string): [string, string] { const [id, node] = nodeNamed(doc, switchName); const used = new Set([...links(doc).values()].flatMap((link) => [`${link.get('sourceNode')}/${link.get('sourcePort')}`, `${link.get('targetNode')}/${link.get('targetPort')}`])); const port = (node.get('ports') as Y.Array>).toArray().find((each) => !used.has(`${id}/${each.get('id')}`))!; return [id, port.get('id') as string]; } function plug(doc: Y.Doc, id: string, [sourceNode, sourcePort]: [string, string], [targetNode, targetPort]: [string, string]): void { const link = new Y.Map(); link.set('order', links(doc).size); link.set('sourceNode', sourceNode); link.set('sourcePort', sourcePort); link.set('targetNode', targetNode); link.set('targetPort', targetPort); links(doc).set(id, link); } describe('two people on one document', () => { it('keeps both changes to two fields of one device', () => { const [first, second] = replicas(); settings(first, 'Notebook 1.10').get('device')!.set('ipAddress', '141.99.1.20'); settings(second, 'Notebook 1.10').get('device')!.set('defaultGateway', '141.99.1.2'); exchange(first, second); for (const doc of [first, second]) { const project = readProject(doc); const id = project.graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!.id; expect(project.deviceConfigurations.get(id)).toMatchObject({ ipAddress: '141.99.1.20', defaultGateway: '141.99.1.2' }); } }); it('keeps both rows two people add to a list at once', () => { const [first, second] = replicas(); const row = (name: string) => { const map = new Y.Map(); map.set('name', name); map.set('type', 'A'); map.set('ttlSeconds', 3600); map.set('target', '141.99.1.10'); return map; }; const records = (doc: Y.Doc) => settings(doc, 'DNS-Server 2.10').get('dnsServer')!.get('records') as Y.Array>; records(first).push([row('eins.filius.de')]); records(second).push([row('zwei.filius.de')]); exchange(first, second); const names = (doc: Y.Doc) => { const project = readProject(doc); const id = project.graph.nodes.find((node) => node.displayName === 'DNS-Server 2.10')!.id; return project.dnsServerConfigurations.get(id)!.records.map((record) => record.name); }; expect(names(first)).toEqual(names(second)); expect(names(first)).toEqual(expect.arrayContaining(['www.filius.de', 'eins.filius.de', 'zwei.filius.de'])); }); it('writes text two people type into one file together', () => { const [first, second] = replicas(); const text = (doc: Y.Doc) => [...files(doc, 'Notebook 1.11').values()].find((entry) => entry.get('kind') === 'text')!.get('text') as Y.Text; const before = text(first).toString(); text(first).insert(0, 'A'); text(second).insert(before.length, 'B'); exchange(first, second); expect(text(first).toString()).toBe(`A${before}B`); expect(text(second).toString()).toBe(`A${before}B`); }); }); describe('the janitor', () => { it('removes a cable to a device removed at the same time (I1)', () => { const [first, second] = replicas(); const [notebook, notebookNode] = nodeNamed(second, 'Notebook 1.11'); const notebookPort = ((notebookNode.get('ports') as Y.Array>).get(0).get('id') as string); // One person removes the notebook with its cable, the other plugs a second cable into it. first.transact(() => { for (const [id, link] of links(first)) if (link.get('sourceNode') === notebook || link.get('targetNode') === notebook) links(first).delete(id); nodes(first).delete(notebook); }); plug(second, 'ffffffff-0000-0000-0000-000000000001', freePort(second, 'Switch 1'), [notebook, notebookPort]); exchange(first, second); expect(links(first).has('ffffffff-0000-0000-0000-000000000001')).toBe(true); expect(repair(first)).toEqual({ danglingLinks: ['ffffffff-0000-0000-0000-000000000001'], sharedPortLinks: [], madeFolders: [] }); expect(links(first).has('ffffffff-0000-0000-0000-000000000001')).toBe(false); // Idempotent: nothing left to do, and nothing written. let updates = 0; first.on('update', () => (updates += 1)); expect(repair(first)).toEqual({ danglingLinks: [], sharedPortLinks: [], madeFolders: [] }); expect(updates).toBe(0); }); it('keeps the cable with the smaller id where two were plugged into one port at once (I2)', () => { const [first, second] = replicas(); const port = freePort(first, 'Switch 1'); plug(first, 'eeeeeeee-0000-0000-0000-000000000002', port, freePort(first, 'Switch 2')); plug(second, 'eeeeeeee-0000-0000-0000-000000000001', port, freePort(second, 'Switch 2')); exchange(first, second); // Both replicas repair, as two janitors would: they end alike. expect(repair(first).sharedPortLinks).toEqual(['eeeeeeee-0000-0000-0000-000000000002']); expect(repair(second).sharedPortLinks).toEqual(['eeeeeeee-0000-0000-0000-000000000002']); exchange(first, second); expect(readProject(first)).toEqual(readProject(second)); expect(links(first).has('eeeeeeee-0000-0000-0000-000000000001')).toBe(true); expect(links(first).has('eeeeeeee-0000-0000-0000-000000000002')).toBe(false); }); it('makes the folder of a file written into a folder removed at the same time (I4)', () => { const [first, second] = replicas(); const folder = [...files(first, 'Notebook 1.11').entries()].find(([path, entry]) => entry.get('kind') === 'directory' && path.split('/').length === 2)![0]; first.transact(() => { for (const path of [...files(first, 'Notebook 1.11').keys()]) if (path === folder || path.startsWith(`${folder}/`)) files(first, 'Notebook 1.11').delete(path); }); const note = new Y.Map(); note.set('order', 1000); note.set('kind', 'text'); note.set('text', new Y.Text('neu')); files(second, 'Notebook 1.11').set(`${folder}/sub/notiz.txt`, note); exchange(first, second); // Read tolerantly before the janitor: the file is left out, the project is whole. const id = nodeNamed(first, 'Notebook 1.11')[0]; expect(readProject(first).virtualFileSystems.get(id)!.contains(`${folder}/sub/notiz.txt`)).toBe(false); expect(repair(first).madeFolders).toEqual([`${id}:${folder}`, `${id}:${folder}/sub`]); expect(readProject(first).virtualFileSystems.get(id)!.textFile(`${folder}/sub/notiz.txt`)).toBe('neu'); expect(repair(first).madeFolders).toEqual([]); }); it('takes a folder in another case as the folder (I4)', () => { const [doc] = replicas(); const [folder] = [...files(doc, 'Notebook 1.11').entries()].find(([path, entry]) => entry.get('kind') === 'directory' && path.split('/').length === 2)!; const note = new Y.Map(); note.set('order', 1000); note.set('kind', 'text'); note.set('text', new Y.Text('neu')); files(doc, 'Notebook 1.11').set(`${folder.toUpperCase()}/notiz.txt`, note); expect(repair(doc).madeFolders).toEqual([]); }); }); describe('a project in its document, beyond the corpus', () => { it('keeps a Java long of an own application, binary files and an optional field left out', () => { const project = opened('java-examples/anwendung_komplett.fls'); const [nodeId, applications] = [...project.ownApplicationsByNodeId][0]!; project.ownApplicationsByNodeId.set(nodeId, [ ...applications, { key: 'eigene', javaClass: 'demo.Eigene', bean: { kind: 'object', className: 'demo.Eigene', statements: [{ set: 'zahl', value: { kind: 'long', value: 12_345_678_901_234_567_890n } }] } }, ]); const fileSystem = project.virtualFileSystems.get(nodeId)!; fileSystem.writeBinaryFile('/daten.bin', new Uint8Array([0, 1, 2, 255])); fileSystem.writeBinaryFile('/typ.bin', new Uint8Array([7]), 'application/octet-stream'); const doc = new Y.Doc(); writeProject(doc, project); const other = new Y.Doc(); Y.applyUpdate(other, Y.encodeStateAsUpdate(doc)); const back = readProject(other); expect(back.ownApplicationsByNodeId).toEqual(project.ownApplicationsByNodeId); expect(back.virtualFileSystems.get(nodeId)!.entry('/daten.bin').content).toEqual({ kind: 'binary', data: new Uint8Array([0, 1, 2, 255]) }); expect(back.virtualFileSystems.get(nodeId)!.entry('/typ.bin').content).toEqual({ kind: 'binary', data: new Uint8Array([7]), mediaType: 'application/octet-stream' }); }); });