Files
mdg-filius/packages/doc/src/janitor.ts
T
julian.kiedaischandClaude Opus 5.5 800e5de1d3 feat(doc): F4.1 the shared document — a project in a Y.Doc and back, the janitor
packages/doc holds the editor's model (ImportedProject) losslessly in a Yjs
document (schema 1): ports with their settings, each node's settings as
maps per field, programs, files as a flat path map in creation order (text
as Y.Text), own Java applications, links, documentation items, archive
entries. The core's snapshot and the .fls export go through the project.
The janitor repairs dangling links, two links at one port and files
without their folder, deterministically and idempotently. All 23 corpus
files come back through a Yjs update with the same snapshot and export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:14:31 +00:00

101 lines
4.3 KiB
TypeScript

// 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<unknown> | undefined): Set<string> {
const ports = node?.get('ports') as Y.Array<Y.Map<unknown>> | 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<Y.Map<unknown>>(ROOTS.nodes);
const links = doc.getMap<Y.Map<unknown>>(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<string>();
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<Y.Map<unknown>>, string[]][] = [];
for (const id of [...nodes.keys()].sort()) {
const files = nodes.get(id)!.get('files') as Y.Map<Y.Map<unknown>> | 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<string, string>();
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<unknown>();
entry.set('order', order++);
entry.set('kind', 'directory');
files.set(folder, entry);
}
}
}, JANITOR_ORIGIN);
return { danglingLinks, sharedPortLinks, madeFolders };
}