// The janitor (plan ยง5.3): what a merge of two documents can leave that the // model cannot hold, repaired deterministically. It depends only on the // document, never on the clock, so that a repair run twice changes nothing // the second time, and two replicas that repair the same document end // alike. Only F runs it, as a client of its own (origin 'janitor'); clients // draw tolerantly instead (a cable without an end is not drawn). // // I1 a cable to a node or port that is gone: removed // I2 two cables at one port (plugged at once): the one with the smaller id stays // I4 a file or folder without its folder: the folder made (paths compared // without case, as the file system does) // // I3 (a switch made smaller than its ports in use) cannot happen here: a // port is an entry of its node, and a cable at a removed port falls under // I1. I5 (a client writing `runtime`) comes with the simulation room (F5); // I6 (values out of range) is marked by the editor, never repaired. import * as Y from 'yjs'; import { parentPath, swiftLowercased } from '@mdg-filius/core'; import { ROOTS } from './projectDoc.js'; export const JANITOR_ORIGIN = 'janitor'; /** What one run repaired, by invariant. */ export interface Repairs { readonly danglingLinks: string[]; readonly sharedPortLinks: string[]; readonly madeFolders: string[]; } /** The ids of a node's ports. */ function portIds(node: Y.Map | undefined): Set { const ports = node?.get('ports') as Y.Array> | undefined; return new Set((ports?.toArray() ?? []).map((port) => port.get('id') as string)); } /** Repairs the document in one transaction (Yjs sends nothing for one that changed nothing). */ export function repair(doc: Y.Doc): Repairs { const nodes = doc.getMap>(ROOTS.nodes); const links = doc.getMap>(ROOTS.links); const danglingLinks: string[] = []; const sharedPortLinks: string[] = []; const madeFolders: string[] = []; // I1, then I2 among the cables left, both in the order of their ids. const ids = [...links.keys()].sort(); const plugged = new Set(); for (const id of ids) { const link = links.get(id)!; const ends = [ [link.get('sourceNode') as string, link.get('sourcePort') as string], [link.get('targetNode') as string, link.get('targetPort') as string], ] as const; if (ends.some(([node, port]) => !portIds(nodes.get(node)).has(port))) { danglingLinks.push(id); continue; } const keys = ends.map(([node, port]) => `${node}/${port}`); if (keys.some((key) => plugged.has(key))) { sharedPortLinks.push(id); continue; } for (const key of keys) plugged.add(key); } // I4: each node's files, in the order of the nodes' ids. const folders: [Y.Map>, string[]][] = []; for (const id of [...nodes.keys()].sort()) { const files = nodes.get(id)!.get('files') as Y.Map> | undefined; if (files === undefined) continue; const known = (path: string) => swiftLowercased(path.normalize('NFC')); const present = new Set([...files.keys()].map(known)); const missing = new Map(); for (const path of files.keys()) { for (let folder = parentPath(path); folder !== '/' && !present.has(known(folder)); folder = parentPath(folder)) { if (!missing.has(known(folder))) missing.set(known(folder), folder); } } if (missing.size === 0) continue; // Shallow first, so that a folder's folder comes before it. const made = [...missing.values()].sort((left, right) => left.split('/').length - right.split('/').length || (left < right ? -1 : left > right ? 1 : 0)); folders.push([files, made]); madeFolders.push(...made.map((folder) => `${id}:${folder}`)); } doc.transact(() => { for (const id of [...danglingLinks, ...sharedPortLinks]) links.delete(id); for (const [files, made] of folders) { // A made folder comes before every entry it holds: below the smallest order. let order = Math.min(...[...files.values()].map((entry) => entry.get('order') as number)) - made.length; for (const folder of made) { const entry = new Y.Map(); entry.set('order', order++); entry.set('kind', 'directory'); files.set(folder, entry); } } }, JANITOR_ORIGIN); return { danglingLinks, sharedPortLinks, madeFolders }; }