66 lines
7.9 KiB
TypeScript
66 lines
7.9 KiB
TypeScript
import {validateCanvasTopology,validateElementOwners,type ElementOwners,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<Id,number>};
|
|
export type ModelPreview = { readonly canvasElementCounts?:CanvasElementCounts;readonly elementOwners?:ElementOwners;readonly sourceCanvases?:readonly CanvasRecord[]; 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 elementOwners:ElementOwners|undefined;let canvasElementCounts:CanvasElementCounts|undefined;const ownerCounts=new Map<Id,number>();
|
|
let header=false,finished=false,projectId='',expected=0,received=0;
|
|
let tombstones:ReadonlyMap<Id,number>=new Map(),published:Document|undefined,snapshot:ModelPreview|undefined;
|
|
const canvases=new Map<Id,CanvasRecord>(),records=new Map<Id,ElementRecord>();
|
|
const key=(kind:'canvas'|'element',id:Id)=>JSON.stringify([kind,id]);
|
|
const known=new Set<string>();
|
|
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<Id>();
|
|
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));}if(value.elementOwners!==undefined){if(!canvasElementCounts)throw fault('invalid_model_identity');validateElementOwners(value.elementOwners,canvasElementCounts,expected);elementOwners=freeze(clone(value.elementOwners));validateCanvasTopology([...canvases.values()],elementOwners,projectId);}header=true;
|
|
}else if(value.projectId!==projectId||value.totalElements!==expected||value.canvases.length||value.tombstones.size||value.canvasElementCounts!==undefined||value.elementOwners!==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<Id>();
|
|
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);if(elementOwners&&(!Object.hasOwn(elementOwners,record.id)||elementOwners[record.id]!==record.ownerCanvasId))throw fault('invalid_model_identity');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<string,{canvas?:CanvasRecord;element?:ElementRecord;deps:Set<string>}>();
|
|
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<string,Set<string>>(),blocked=new Set<string>(),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;index<queue.length;index++)for(const id of dependants.get(queue[index])??[])if(!blocked.has(id)){blocked.add(id);queue.push(id);}
|
|
const nextCanvases=[...(published?.canvases.values()??[])],nextElements=[...(published?.elements.values()??[])],accepted:string[]=[];
|
|
for(const [id,item]of candidates)if(!blocked.has(id)){if(item.canvas)nextCanvases.push(item.canvas);if(item.element)nextElements.push(item.element);accepted.push(id);}
|
|
if(!published||accepted.length){const next=createDocument(projectId,nextCanvases,nextElements,tombstones);validateDocument(next,registry);published=next;for(const id of accepted){known.add(id);const item=candidates.get(id);if(item?.element)records.delete(item.element.id);}}
|
|
snapshot=Object.freeze({...(elementOwners?{elementOwners,sourceCanvases:freeze([...canvases.values()])}:{}),...(canvasElementCounts?{canvasElementCounts}:{}),document:published,receivedElements:received,totalElements:expected,waitingElements:received-published.elements.size,complete:false});return snapshot;
|
|
}
|
|
return Object.freeze({update(value:CanonicalReadUpdate){try{return update(value);}catch(error){finished=true;throw error;}},getSnapshot:()=>snapshot,close(){finished=true;records.clear();canvases.clear();known.clear();published=undefined;snapshot=undefined;}});
|
|
}
|