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>
This commit is contained in:
2026-10-03 08:14:31 +00:00
co-authored by Claude Opus 5.5
parent 49b8fe715e
commit 800e5de1d3
16 changed files with 927 additions and 3 deletions
+333
View File
@@ -0,0 +1,333 @@
// A project in its shared document and back (plan §5.2, schema 1). The
// document holds the editor's model — what the .fls reader makes of a file,
// `ImportedProject` — so that its snapshot for the core (projectSnapshot)
// and its export (saveProjectArchive) stay the one translation each, and a
// project read back from its document is the project written into it.
//
// meta schema, versionLine
// nodes id → { order, kind, name, labelPolicy, x, y,
// ports: [{ id, label, occupied, mac?, config? }],
// settings: { device?, routes?, firewall?, dnsServer?, … },
// programs: program → { active },
// files?: path → { order, kind, text | data, mediaType? },
// own? }
// links id → { order, sourceNode, sourcePort, targetNode, targetPort }
// docs id → { kind, x, y, width, height, text, red, green, blue, alpha,
// font, size, bold, order }
// archive name → bytes (the project's own Java applications)
//
// Departures from §5.2, to keep the model whole: a port's settings are the
// model's InterfaceConfiguration under `config`, a node's settings its
// configurations by kind under `settings`; a node's and a link's place in
// the file is `order`; bytes stay in the document until F holds blobs (F5).
import * as Y from 'yjs';
import {
inDeterministicRenderOrder,
interfaceKey,
VirtualFileSystem,
type DocumentationItem,
type Id,
type InstallableProgram,
type TopologyLink,
type TopologyNode,
type VirtualFileContent,
} from '@mdg-filius/core';
import type { FlsOwnApplication, ImportedProject } from '@mdg-filius/fls';
import { fromY, toY, type PlainValue } from './values.js';
export const SCHEMA_VERSION = 1;
/** The document's root types. */
export const ROOTS = { meta: 'meta', nodes: 'nodes', links: 'links', docs: 'docs', archive: 'archive' } as const;
type NodeSettingsField =
| 'deviceConfigurations'
| 'switchConfigurations'
| 'hostWirelessConfigurations'
| 'manualRoutesByNodeId'
| 'ripEnabledByNodeId'
| 'ipForwardingByNodeId'
| 'firewallConfigurationsByNodeId'
| 'dhcpClientConfigurations'
| 'dhcpServerConfigurations'
| 'portForwardingRowsByNodeId'
| 'remoteLinkConfigurations'
| 'webServerConfigurations'
| 'gnutellaConfigurations'
| 'dnsServerConfigurations'
| 'emailClientConfigurations'
| 'emailServerConfigurations'
| 'echoServerConfigurations'
| 'simpleClientConfigurations';
/** A node's settings in the document, by the model's map they come from. */
export const NODE_SETTINGS: Readonly<Record<NodeSettingsField, string>> = {
deviceConfigurations: 'device',
switchConfigurations: 'switch',
hostWirelessConfigurations: 'wireless',
manualRoutesByNodeId: 'routes',
ripEnabledByNodeId: 'rip',
ipForwardingByNodeId: 'ipForwarding',
firewallConfigurationsByNodeId: 'firewall',
dhcpClientConfigurations: 'dhcpClient',
dhcpServerConfigurations: 'dhcpServer',
portForwardingRowsByNodeId: 'portForwarding',
remoteLinkConfigurations: 'remoteLink',
webServerConfigurations: 'webServer',
gnutellaConfigurations: 'gnutella',
dnsServerConfigurations: 'dnsServer',
emailClientConfigurations: 'emailClient',
emailServerConfigurations: 'emailServer',
echoServerConfigurations: 'echoServer',
simpleClientConfigurations: 'simpleClient',
};
const SETTINGS_FIELDS = Object.keys(NODE_SETTINGS) as NodeSettingsField[];
/** The project's own applications as text: their beans are never edited, and hold Java longs (bigint). */
function ownText(applications: readonly FlsOwnApplication[]): string {
return JSON.stringify(applications, (_, value: unknown) => (typeof value === 'bigint' ? { bigint: value.toString() } : value));
}
function ownApplications(text: string): FlsOwnApplication[] {
return JSON.parse(text, (_, value: unknown) =>
value !== null && typeof value === 'object' && Object.keys(value).length === 1 && typeof (value as { bigint?: unknown }).bigint === 'string'
? BigInt((value as { bigint: string }).bigint)
: value,
) as FlsOwnApplication[];
}
function fileEntry(order: number, content: VirtualFileContent): Y.Map<unknown> {
const entry = new Y.Map<unknown>();
entry.set('order', order);
entry.set('kind', content.kind);
if (content.kind === 'text') entry.set('text', new Y.Text(content.text));
if (content.kind === 'binary' || content.kind === 'image') entry.set('data', content.data);
if ((content.kind === 'binary' || content.kind === 'image') && content.mediaType !== undefined) entry.set('mediaType', content.mediaType);
return entry;
}
/** A device's files, each folder's entries in the order they were made (parents before their entries). */
function filesMap(files: VirtualFileSystem): Y.Map<Y.Map<unknown>> {
const map = new Y.Map<Y.Map<unknown>>();
let order = 0;
const walk = (folder: string): void => {
for (const entry of files.entriesInCreationOrder(folder)) {
map.set(entry.path, fileEntry(order++, entry.content));
if (entry.content.kind === 'directory') walk(entry.path);
}
};
walk('/');
return map;
}
function nodeMap(node: TopologyNode, order: number, project: ImportedProject): Y.Map<unknown> {
const map = new Y.Map<unknown>();
map.set('order', order);
map.set('kind', node.kind);
map.set('name', node.displayName);
map.set('labelPolicy', node.hostLabelPolicy);
map.set('x', node.position.x);
map.set('y', node.position.y);
const ports = new Y.Array<Y.Map<unknown>>();
ports.push(
node.ports.map((port) => {
const entry = new Y.Map<unknown>();
entry.set('id', port.id);
entry.set('label', port.label);
entry.set('occupied', port.isOccupied);
if (port.importedMacAddress !== undefined) entry.set('mac', port.importedMacAddress);
const config = project.interfaceConfigurations.get(interfaceKey(node.id, port.id));
if (config !== undefined) entry.set('config', toY(config as unknown as PlainValue));
return entry;
}),
);
map.set('ports', ports);
const settings = new Y.Map<unknown>();
for (const field of SETTINGS_FIELDS) {
const value = (project[field] as ReadonlyMap<Id, unknown>).get(node.id);
if (value !== undefined) settings.set(NODE_SETTINGS[field], toY(value as PlainValue));
}
map.set('settings', settings);
const programs = new Y.Map<Y.Map<unknown>>();
for (const program of project.installedPrograms.get(node.id) ?? []) {
const entry = new Y.Map<unknown>();
entry.set('active', project.activePrograms.get(node.id)?.has(program) === true);
programs.set(program, entry);
}
map.set('programs', programs);
const files = project.virtualFileSystems.get(node.id);
if (files !== undefined) map.set('files', filesMap(files));
const own = project.ownApplicationsByNodeId.get(node.id);
if (own !== undefined) map.set('own', ownText(own));
return map;
}
function linkMap(link: TopologyLink, order: number): Y.Map<unknown> {
const map = new Y.Map<unknown>();
map.set('order', order);
map.set('sourceNode', link.sourceNodeId);
map.set('sourcePort', link.sourcePortId);
map.set('targetNode', link.targetNodeId);
map.set('targetPort', link.targetPortId);
return map;
}
function documentationMap(item: DocumentationItem): Y.Map<unknown> {
const map = new Y.Map<unknown>();
map.set('kind', item.kind);
map.set('x', item.frame.x);
map.set('y', item.frame.y);
map.set('width', item.frame.width);
map.set('height', item.frame.height);
map.set('text', new Y.Text(item.text));
map.set('red', item.color.red);
map.set('green', item.color.green);
map.set('blue', item.color.blue);
map.set('alpha', item.color.alpha);
map.set('font', item.fontName);
map.set('size', item.fontSize);
map.set('bold', item.isBold);
map.set('order', item.order);
return map;
}
/** Writes the project into an empty document, in one transaction. */
export function writeProject(doc: Y.Doc, project: ImportedProject, origin: unknown = null): void {
doc.transact(() => {
const meta = doc.getMap(ROOTS.meta);
meta.set('schema', SCHEMA_VERSION);
meta.set('versionLine', project.versionLine);
const nodes = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
project.graph.nodes.forEach((node, index) => nodes.set(node.id, nodeMap(node, index, project)));
const links = doc.getMap<Y.Map<unknown>>(ROOTS.links);
project.graph.links.forEach((link, index) => links.set(link.id, linkMap(link, index)));
const docs = doc.getMap<Y.Map<unknown>>(ROOTS.docs);
for (const item of project.documentationItems) docs.set(item.id, documentationMap(item));
const archive = doc.getMap<Uint8Array>(ROOTS.archive);
for (const [name, bytes] of project.archiveEntries) archive.set(name, bytes);
}, origin);
}
/** The entries of a Y.Map in their `order`, then by key: a place two people gave at once is decided the same everywhere. */
export function inOrder(map: Y.Map<Y.Map<unknown>>): [string, Y.Map<unknown>][] {
return [...map.entries()].sort(([leftKey, left], [rightKey, right]) => {
const byOrder = (left.get('order') as number) - (right.get('order') as number);
return byOrder !== 0 ? byOrder : leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
});
}
/**
* A device's files. Read tolerantly, as the editor draws: an entry the file
* system refuses (its folder missing before the janitor ran, or a name two
* people gave at once in different case) is left out, not the project.
*/
function fileSystemOf(files: Y.Map<Y.Map<unknown>>): VirtualFileSystem {
const fileSystem = new VirtualFileSystem();
for (const [path, entry] of inOrder(files)) {
const kind = entry.get('kind') as VirtualFileContent['kind'];
const mediaType = entry.get('mediaType') as string | undefined;
try {
if (kind === 'directory') fileSystem.createDirectory(path);
else if (kind === 'text') fileSystem.writeTextFile(path, (entry.get('text') as Y.Text).toString());
else if (kind === 'image') fileSystem.writeImageFile(path, entry.get('data') as Uint8Array, mediaType!);
else fileSystem.writeBinaryFile(path, entry.get('data') as Uint8Array, mediaType);
} catch {
// Left out (see above).
}
}
return fileSystem;
}
/** The project the document holds; its warnings are the reader's and stay with the import. */
export function readProject(doc: Y.Doc): ImportedProject {
const meta = doc.getMap(ROOTS.meta);
const project: ImportedProject = {
versionLine: (meta.get('versionLine') as string | null | undefined) ?? null,
graph: { nodes: [], links: [] },
deviceConfigurations: new Map(),
interfaceConfigurations: new Map(),
switchConfigurations: new Map(),
hostWirelessConfigurations: new Map(),
manualRoutesByNodeId: new Map(),
ripEnabledByNodeId: new Map(),
ipForwardingByNodeId: new Map(),
firewallConfigurationsByNodeId: new Map(),
dhcpClientConfigurations: new Map(),
dhcpServerConfigurations: new Map(),
portForwardingRowsByNodeId: new Map(),
remoteLinkConfigurations: new Map(),
virtualFileSystems: new Map(),
installedPrograms: new Map(),
activePrograms: new Map(),
webServerConfigurations: new Map(),
gnutellaConfigurations: new Map(),
dnsServerConfigurations: new Map(),
emailClientConfigurations: new Map(),
emailServerConfigurations: new Map(),
echoServerConfigurations: new Map(),
simpleClientConfigurations: new Map(),
documentationItems: [],
archiveEntries: new Map(doc.getMap<Uint8Array>(ROOTS.archive).entries()),
ownApplicationsByNodeId: new Map(),
warnings: [],
};
for (const [id, map] of inOrder(doc.getMap<Y.Map<unknown>>(ROOTS.nodes))) {
const ports = (map.get('ports') as Y.Array<Y.Map<unknown>>).toArray();
project.graph.nodes.push({
id,
kind: map.get('kind') as TopologyNode['kind'],
displayName: map.get('name') as string,
hostLabelPolicy: map.get('labelPolicy') as TopologyNode['hostLabelPolicy'],
position: { x: map.get('x') as number, y: map.get('y') as number },
ports: ports.map((port) => {
const mac = port.get('mac') as string | undefined;
const fields = { id: port.get('id') as Id, label: port.get('label') as string, isOccupied: port.get('occupied') as boolean };
return mac === undefined ? fields : { ...fields, importedMacAddress: mac };
}),
});
for (const port of ports) {
const config = port.get('config');
if (config !== undefined) project.interfaceConfigurations.set(interfaceKey(id, port.get('id') as Id), fromY(config) as never);
}
const settings = map.get('settings') as Y.Map<unknown>;
for (const field of SETTINGS_FIELDS) {
const value = settings.get(NODE_SETTINGS[field]);
if (value !== undefined) (project[field] as Map<Id, unknown>).set(id, fromY(value));
}
const programs = map.get('programs') as Y.Map<Y.Map<unknown>>;
if (programs.size > 0) {
project.installedPrograms.set(id, new Set([...programs.keys()] as InstallableProgram[]));
const active = [...programs.entries()].filter(([, entry]) => entry.get('active') === true).map(([program]) => program as InstallableProgram);
if (active.length > 0) project.activePrograms.set(id, new Set(active));
}
const files = map.get('files') as Y.Map<Y.Map<unknown>> | undefined;
if (files !== undefined) project.virtualFileSystems.set(id, fileSystemOf(files));
const own = map.get('own') as string | undefined;
if (own !== undefined) project.ownApplicationsByNodeId.set(id, ownApplications(own));
}
for (const [id, map] of inOrder(doc.getMap<Y.Map<unknown>>(ROOTS.links))) {
project.graph.links.push({
id,
sourceNodeId: map.get('sourceNode') as Id,
sourcePortId: map.get('sourcePort') as Id,
targetNodeId: map.get('targetNode') as Id,
targetPortId: map.get('targetPort') as Id,
});
}
project.documentationItems = inDeterministicRenderOrder(
[...doc.getMap<Y.Map<unknown>>(ROOTS.docs).entries()].map(([id, map]) => ({
id,
kind: map.get('kind') as DocumentationItem['kind'],
frame: { x: map.get('x') as number, y: map.get('y') as number, width: map.get('width') as number, height: map.get('height') as number },
text: (map.get('text') as Y.Text).toString(),
color: { red: map.get('red') as number, green: map.get('green') as number, blue: map.get('blue') as number, alpha: map.get('alpha') as number },
fontName: map.get('font') as string,
fontSize: map.get('size') as number,
isBold: map.get('bold') as boolean,
order: map.get('order') as number,
})),
);
return project;
}