import {validateCanvasElementCounts,type CanvasElementCounts} from './scope-counts.ts'; import type { CanvasRecord, Document, ElementRecord, Id, Registry } from '../canvas-core/types.ts'; import { ancestors, createDocument, validateDocument, validateElementRecord } from '../canvas-core/document.ts'; import { copyReferenceIds } from '../canvas-core/copy-paths.ts'; import { clone, equal, fault, freeze, readMap } from '../canvas-core/values.ts'; import type { CanonicalReadUpdate } from './contracts.ts'; export type LiveModelPreview=ModelPreview&{readonly loading:import('./loading.ts').LoadingState;readonly versions:ReadonlyMap}; export type ModelPreview = { readonly canvasElementCounts?:CanvasElementCounts; readonly document:Document;readonly receivedElements:number;readonly totalElements:number;readonly waitingElements:number;readonly complete:false }; /** An ephemeral read projection. It owns neither a working document nor ACKs, * history, operations, file bytes or a durable recovery cut. */ export function createModelPreview(registry:Registry){ let canvasElementCounts:CanvasElementCounts|undefined;const ownerCounts=new Map(); let header=false,finished=false,projectId='',expected=0,received=0; let tombstones:ReadonlyMap=new Map(),published:Document|undefined,snapshot:ModelPreview|undefined; const canvases=new Map(),records=new Map(); const key=(kind:'canvas'|'element',id:Id)=>JSON.stringify([kind,id]); const known=new Set(); function update(value:CanonicalReadUpdate):ModelPreview|undefined{ if(finished)throw fault('model_preview_closed'); if(value.status!=='receiving'){ if(header&&(value.status!=='ready'||value.document.projectId!==projectId||received!==expected||value.document.canvases.size!==canvases.size||[...canvases].some(([id,canvas])=>!equal(value.document.canvases.get(id),canvas))||!equal(Object.fromEntries(value.document.tombstones),Object.fromEntries(tombstones))||value.document.elements.size!==expected||[...(published?.elements??[]),...records].some(([id,record])=>!equal(value.document.elements.get(id),record))))throw fault('model_preview_changed'); finished=true;return snapshot; } if(!header){ if(value.receivedElements!==0||value.elements.length||!value.canvases.length||!Number.isSafeInteger(value.totalElements)||value.totalElements<0)throw fault('invalid_model_preview'); projectId=value.projectId;expected=value.totalElements;tombstones=readMap(new Map(value.tombstones)); for(const canvas of value.canvases){if(canvases.has(canvas.id))throw fault('invalid_model_preview');canvases.set(canvas.id,freeze(clone(canvas)));} const topology=createDocument(projectId,[...canvases.values()]);let roots=0;const entries=new Set(); for(const canvas of canvases.values()){ if(!canvas.id||!Object.hasOwn(canvas,'parent'))throw fault('invalid_canvas'); ancestors(topology,canvas.id); if(canvas.parent===null){roots++;if(canvas.id!==projectId)throw fault('project_mismatch');} else if(!canvas.parent||typeof canvas.parent.entryElementId!=='string'||!canvas.parent.entryElementId||entries.has(canvas.parent.entryElementId))throw fault('invalid_canvas_entry'); else entries.add(canvas.parent.entryElementId); } if(roots!==1)throw fault('invalid_roots');if(value.canvasElementCounts!==undefined){validateCanvasElementCounts(value.canvasElementCounts,[...canvases.keys()],expected);canvasElementCounts=freeze(clone(value.canvasElementCounts));}header=true; }else if(value.projectId!==projectId||value.totalElements!==expected||value.canvases.length||value.tombstones.size||value.canvasElementCounts!==undefined)throw fault('model_preview_changed'); if(value.receivedElements!==received+value.elements.length||value.receivedElements>expected)throw fault('invalid_model_preview'); // Validate the entire incoming batch before publishing any prefix from it. const incoming=new Set(); for(const record of value.elements){if(records.has(record.id)||known.has(key('element',record.id))||incoming.has(record.id)||!canvases.has(record.ownerCanvasId))throw fault('invalid_model_preview');validateElementRecord(record,registry,tombstones);incoming.add(record.id);} for(const record of value.elements){const count=(ownerCounts.get(record.ownerCanvasId)??0)+1;if(canvasElementCounts&&count>canvasElementCounts[record.ownerCanvasId])throw fault('invalid_model_coverage');ownerCounts.set(record.ownerCanvasId,count);} for(const record of value.elements)records.set(record.id,freeze(clone(record)));received=value.receivedElements; // Greatest closed subset: removing an unavailable dependency also removes its // dependants. Fully received cycles survive and publish in one legal prefix. const candidates=new Map}>(); for(const canvas of canvases.values())if(!known.has(key('canvas',canvas.id)))candidates.set(key('canvas',canvas.id),{canvas,deps:new Set(canvas.parent?[key('canvas',canvas.parent.canvasId),key('element',canvas.parent.entryElementId)]:[])}); for(const element of records.values())if(!known.has(key('element',element.id))){ const deps=new Set([key('canvas',element.ownerCanvasId)]); if(element.structure==='connection'){deps.add(key('element',element.source.elementId));deps.add(key('element',element.target.elementId));} const definition=registry.resolve(element)==='known'?registry.components.get(element.componentId):undefined; for(const ref of definition?.copy?.references??[])for(const id of copyReferenceIds(element.data,ref))deps.add(key(ref.target,id)); candidates.set(key('element',element.id),{element,deps}); } const dependants=new Map>(),blocked=new Set(),queue:string[]=[]; for(const [id,item]of candidates)for(const dependency of item.deps){let users=dependants.get(dependency);if(!users){users=new Set();dependants.set(dependency,users);}users.add(id);if(!known.has(dependency)&&!candidates.has(dependency)&&!blocked.has(id)){blocked.add(id);queue.push(id);}} for(let index=0;indexsnapshot,close(){finished=true;records.clear();canvases.clear();known.clear();published=undefined;snapshot=undefined;}}); }