126 lines
10 KiB
TypeScript
126 lines
10 KiB
TypeScript
import type { CanvasRecord, Change, Document, ElementRecord, Geometry, Id, Registry, Target } from './types.ts';
|
|
import { clone, equal, fault, freeze, readMap, validJson } from './values.ts';
|
|
|
|
export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap<Id, number> = new Map()): Document {
|
|
for (const element of elements) { const id = element.id; if (!validJson(element)) throw fault('invalid_element', [id]); }
|
|
for (const canvas of canvases) if (!validJson(canvas)) throw fault('invalid_canvas');
|
|
if (!projectId || new Set(canvases.map(item => item.id)).size !== canvases.length || new Set(elements.map(item => item.id)).size !== elements.length) throw fault('duplicate_identity');
|
|
return Object.freeze({ projectId, canvases: readMap(new Map(canvases.map(item => [item.id, freeze(clone(item))]))), elements: readMap(new Map(elements.map(item => [item.id, freeze(clone(item))]))), tombstones: readMap(tombstones) });
|
|
}
|
|
export function geometryValid(value: Geometry): boolean {
|
|
return !!value && typeof value === 'object' && [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360;
|
|
}
|
|
export function targetElement(document: Document, target: Target): ElementRecord {
|
|
const element = document.elements.get(target.id);
|
|
if (!element) throw fault('element_missing', [target.id]);
|
|
if (element.incarnation !== target.incarnation) throw fault('stale_incarnation', [target.id]);
|
|
return element;
|
|
}
|
|
export function ancestors(document: Document, canvasId: Id): Id[] {
|
|
const result: Id[] = [], visited = new Set<Id>();
|
|
let next: Id | undefined = canvasId;
|
|
while (next) {
|
|
if (visited.has(next)) throw fault('canvas_cycle', [next]);
|
|
visited.add(next); result.push(next);
|
|
const canvas: CanvasRecord | undefined = document.canvases.get(next);
|
|
if (!canvas) throw fault('canvas_not_loaded', [next]);
|
|
next = canvas.parent?.canvasId;
|
|
}
|
|
return result;
|
|
}
|
|
export function commonCanvas(document: Document, left: Id, right: Id): Id {
|
|
const parents = new Set(ancestors(document, left));
|
|
const result = ancestors(document, right).find(id => parents.has(id));
|
|
if (!result) throw fault('no_common_canvas', [left, right]);
|
|
return result;
|
|
}
|
|
/** Validate one record before its graph dependencies have arrived. This does not
|
|
* prove owner membership, endpoint existence or connection common ownership. */
|
|
export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap<Id,number> = new Map(), componentData = true): void {
|
|
if (!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data)) throw fault('invalid_element', [element?.id]);
|
|
const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]);
|
|
const definition=registry.components.get(element.componentId);
|
|
if(componentData&®istry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!definition.validate(element.data)))throw fault('invalid_component_data',[element.id]);
|
|
if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);}
|
|
else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]);
|
|
}
|
|
|
|
/** Cooperative validation over an immutable document; synchronous callers drain the same checks. */
|
|
function* validateDocumentChecks(document: Document, registry: Registry, componentData:boolean): Generator<void, void> {
|
|
if (!document.canvases.size) throw fault('missing_root');
|
|
for (const [id, incarnation] of document.tombstones) { yield; if (!id || !Number.isSafeInteger(incarnation) || incarnation < 1) throw fault('invalid_tombstone', [id]); }
|
|
const entries = new Set<Id>();
|
|
let roots = 0;
|
|
for (const canvas of document.canvases.values()) {
|
|
yield;
|
|
if (typeof canvas.id !== 'string' || !canvas.id || !Object.hasOwn(canvas,'parent') || canvas.parent !== null && (!canvas.parent || typeof canvas.parent.canvasId !== 'string' || typeof canvas.parent.entryElementId !== 'string')) throw fault('invalid_canvas');
|
|
ancestors(document, canvas.id);
|
|
if (!canvas.parent) { roots++; continue; }
|
|
const entry = document.elements.get(canvas.parent.entryElementId);
|
|
if (!entry || entry.structure !== 'node' || entry.ownerCanvasId !== canvas.parent.canvasId || entries.has(entry.id)) throw fault('invalid_canvas_entry', [canvas.id]);
|
|
entries.add(entry.id);
|
|
}
|
|
if (roots !== 1) throw fault('invalid_roots');
|
|
for (const element of document.elements.values()) {
|
|
yield;
|
|
validateElementRecord(element, registry, document.tombstones, componentData);
|
|
if (!document.canvases.has(element.ownerCanvasId)) throw fault('invalid_element', [element.id]);
|
|
if (element.structure === 'connection') {
|
|
const source = document.elements.get(element.source.elementId), target = document.elements.get(element.target.elementId);
|
|
if (!source || !target || source.structure !== 'node' || target.structure !== 'node' || !validJson(element.path)) throw fault('invalid_endpoint', [element.id]);
|
|
if (commonCanvas(document, source.ownerCanvasId, target.ownerCanvasId) !== element.ownerCanvasId) throw fault('invalid_connection_owner', [element.id]);
|
|
}
|
|
}
|
|
}
|
|
/** Structural indexes contain no component payload; use only for source planning, never editor publication. */
|
|
export function* validateDocumentTopologySteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,false);}
|
|
export function* validateDocumentSteps(document:Document,registry:Registry):Generator<void,void>{yield* validateDocumentChecks(document,registry,true);}
|
|
export function validateDocument(document: Document, registry: Registry): void {
|
|
for (const _ of validateDocumentSteps(document, registry)) { /* Drain identical validation without scheduling. */ }
|
|
}
|
|
export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
|
|
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
|
|
for (const change of changes) {
|
|
if (change.kind === 'field') {
|
|
if (!validJson(change.after)) throw fault('invalid_change');
|
|
if (!change.field.startsWith('data.') && !['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(change.field)) throw fault('invalid_field');
|
|
const element = elements.get(change.target.id);
|
|
if (!element || element.incarnation !== change.target.incarnation) throw fault('stale_incarnation', [change.target.id]);
|
|
const dataKey = change.field.startsWith('data.') ? change.field.slice(5) : null;
|
|
const prior = dataKey !== null ? element.data[dataKey] ?? null : (element as unknown as Record<string, unknown>)[change.field];
|
|
if (conditional && (dataKey !== null && Object.hasOwn(element.data, dataKey) !== (change.beforePresent ?? true) || !equal(prior, change.before))) throw fault('field_conflict', [element.id]);
|
|
if (dataKey !== null) {
|
|
if (!dataKey || ['__proto__', 'prototype', 'constructor'].includes(dataKey)) throw fault('invalid_field');
|
|
const data = { ...element.data };
|
|
if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after);
|
|
elements.set(element.id, freeze({ ...element, data }));
|
|
} else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord);
|
|
} else if (change.kind === 'element') {
|
|
if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]);
|
|
if (change.after) {
|
|
if (!validJson(change.after)) throw fault('invalid_element', [change.id]);
|
|
if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]);
|
|
elements.set(change.id, freeze(clone(change.after)));
|
|
} else {
|
|
const old = elements.get(change.id);
|
|
if (old) tombstones.set(change.id, Math.max(tombstones.get(change.id) || 0, old.incarnation));
|
|
elements.delete(change.id);
|
|
}
|
|
} else if (change.kind === 'canvas') {
|
|
if (conditional && !equal(canvases.get(change.id) || null, change.before)) throw fault('canvas_conflict', [change.id]);
|
|
if (change.after) {
|
|
if (!validJson(change.after)) throw fault('invalid_canvas', [change.id]);
|
|
if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]);
|
|
canvases.set(change.id, freeze(clone(change.after)));
|
|
} else canvases.delete(change.id);
|
|
} else throw fault('invalid_change');
|
|
}
|
|
return Object.freeze({ projectId: document.projectId, elements: readMap(elements), canvases: readMap(canvases), tombstones: readMap(tombstones) });
|
|
}
|
|
export function invert(changes: readonly Change[]): readonly Change[] {
|
|
return changes.slice().reverse().map(change => {
|
|
if(change.kind==='field'){const{beforePresent,afterPresent,...record}=change;return freeze({...record,before:change.after,after:change.before,...(afterPresent===undefined?{}:{beforePresent:afterPresent}),...(beforePresent===undefined?{}:{afterPresent:beforePresent})});}
|
|
return freeze({...change,before:change.after,after:change.before}) as Change;
|
|
});
|
|
}
|