Allow durable imports and deep copies in certified loading scopes
This commit is contained in:
@@ -41,12 +41,13 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
|
||||
if (liveView) lastView.current = liveView;
|
||||
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
|
||||
const canCreate=!loading||loading.version===4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
|
||||
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
|
||||
useEffect(()=>{if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.elements.size)return;needsLoadingFit.current=false;void flow.fitView({padding:.2,maxZoom:1});},[flow,root.document]);
|
||||
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
|
||||
const [editorFocused, setEditorFocused] = useState(false), focusEpoch = useRef(0);
|
||||
useEffect(() => { focusEpoch.current++; setEditorFocused(false); return () => { focusEpoch.current++; }; }, [engine,viewId]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
|
||||
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
|
||||
@@ -246,7 +247,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
</EdgeEditing.Provider></NodeEditing.Provider></CanvasRendererCommandsContext.Provider></ImagePreviews.Provider>
|
||||
<div className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
||||
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={!!loading || copying || preparingCopy || !view.selection.size} onClick={() => {
|
||||
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
|
||||
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
|
||||
setCopying(true); setError("");
|
||||
void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); });
|
||||
@@ -261,7 +262,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
<IconButton icon={Type} label="添加纯文本" disabled={!canCreate} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!canCreate} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!!loading} onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" disabled={!canCreate} onClick={() => add('frame')} />
|
||||
</div>
|
||||
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
|
||||
{!loading&&<HeadlessImports engine={engine} canvasId={view.canvasId} />}
|
||||
{(!loading||loading.version===5)&&<HeadlessImports engine={engine} canvasId={view.canvasId} />}
|
||||
{showAI && aiElement && <HeadlessAIPanel key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
|
||||
{showRecovery && <HeadlessRecoveryDialog engine={engine} close={() => setShowRecovery(false)} />}
|
||||
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; c
|
||||
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
|
||||
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
|
||||
return <aside className={styles.importPanel} aria-label="画布导入">
|
||||
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入 NDJSON 画布数据" onClick={() => input.current?.click()} /><span>逐行画布数据</span></div>
|
||||
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入 NDJSON 画布数据" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>逐行画布数据</span></div>
|
||||
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,application/x-ndjson" hidden onChange={event => {
|
||||
const file = event.target.files?.[0]; event.target.value = ""; if (!file) return;
|
||||
try { engine.imports.startNdjson(file, canvasId); setOpen(true); setError(""); } catch (error) { setError(errorText(error)); }
|
||||
|
||||
@@ -68,6 +68,11 @@ export const errorText = (error: unknown) => {
|
||||
loading_reference_incomplete: "相关引用尚未完整加载,暂时无法修改。",
|
||||
invalid_model_coverage: "画布的加载范围信息不一致,请重新加载。",
|
||||
loading_waiting_for_receipts: "正在确认已发送的修改,请稍后重试。",
|
||||
invalid_model_topology: "画布的分区关系不一致,请重新加载。",
|
||||
loading_scope_incomplete: "所需分区或上级引用尚未完整加载,请稍后重试。",
|
||||
invalid_loading_import: "导入任务的加载范围记录不完整,任务已停止。",
|
||||
loading_copy_scope_changed: "复制源的范围校验失败,请重新复制。",
|
||||
loading_import_interpreter_unavailable: "此导入任务需要更新版本后才能恢复。",
|
||||
model_loading: "画布尚在加载,请等待加载完成后编辑。",
|
||||
identity_changed: "账户已变化,当前画布已停止同步,请重新打开。",
|
||||
engine_closing: "画布正在关闭,本地修改会保留用于恢复。",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {loadingImportAdmission,validateLoadingCopySource} from './loading-import.ts';
|
||||
import {loadingCanSend,mergeLoadingRead,loadingQueryCoverage,assertLoadingChanges,loadingCommand,validateLoadingRuntime,validatePrefixExtension,validateLoadingCompletion,type LoadingState} from './loading.ts';
|
||||
import {validImageDimensions,type ImageDecoderPort} from './image-decoder.ts';
|
||||
import {unitScale} from '../canvas-core/units.ts';
|
||||
@@ -102,7 +103,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
validateLoadingRuntime(initial,options.registry);
|
||||
const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit,queryBudget:options.queryBudget,queryCoverage:loadingQueryCoverage,
|
||||
policy(command, document) { if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if (closing || closed || recovery) return { enabled: false, code: closing||closed?'engine_closing':'recovery_in_progress', targets: [] }; if(editor.getSnapshot().runtime.loading){const allowed=loadingCommand(command,document,options.registry,editor.getSnapshot().runtime.loading,editor.getSnapshot().runtime.loadingRead?.document??editor.getSnapshot().runtime.acknowledged,editor.getSnapshot().runtime.loadingRead);if(!allowed.enabled)return allowed;}return options.policy?.(command, document) ?? { enabled: true }; },
|
||||
traversalPolicy(entry, direction) { if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if(closing||closed||recovery)return{enabled:false,code:closing||closed?'engine_closing':'recovery_in_progress',targets:[]};if(editor.getSnapshot().runtime.loading&&editor.getSnapshot().runtime.loading?.version!==4){try{assertLoadingChanges(entry.changes,editor.getSnapshot().document,options.registry,editor.getSnapshot().runtime.loading,editor.getSnapshot().runtime.loadingRead);}catch(error){return{enabled:false,code:(error as Error).message,targets:[]};}}return options.traversalPolicy?.(entry,direction)??{enabled:true}; },
|
||||
traversalPolicy(entry, direction) { if(options.isCurrentIdentity&&!options.isCurrentIdentity())return{enabled:false,code:'identity_changed',targets:[]};if(closing||closed||recovery)return{enabled:false,code:closing||closed?'engine_closing':'recovery_in_progress',targets:[]};if(editor.getSnapshot().runtime.loading&&(editor.getSnapshot().runtime.loading?.version??0)<4){try{assertLoadingChanges(entry.changes,editor.getSnapshot().document,options.registry,editor.getSnapshot().runtime.loading,editor.getSnapshot().runtime.loadingRead);}catch(error){return{enabled:false,code:(error as Error).message,targets:[]};}}return options.traversalPolicy?.(entry,direction)??{enabled:true}; },
|
||||
onListenerError: report,
|
||||
prepareRuntime:(previous,next)=>{
|
||||
if(!acceptingLoading&&!recovery&&(previous.runtime.loading||next.runtime.loading||previous.runtime.loadingRead||next.runtime.loadingRead)&&(!equal(previous.runtime.loading,next.runtime.loading)||previous.runtime.loadingRead!==next.runtime.loadingRead||previous.runtime.acknowledged!==next.runtime.acknowledged||!equal(Object.fromEntries(previous.runtime.versions),Object.fromEntries(next.runtime.versions))))throw fault('loading_state_owned');
|
||||
@@ -116,7 +117,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
prepareIntent: intent => applying && intent.origin==='user' ? {...intent,operationType:'run.apply',action:applying} : intent,
|
||||
nextOperationSequence(runtime, fallback) { let next = fallback; for (const record of runtime.ledger.operations.values()) next = Math.max(next, record.intent.sequence + 1); return next + staged.length; },
|
||||
reduceRuntime(previous, next, commit) {
|
||||
if(previous.loading)assertLoadingChanges(commit.changes,previous.loading.version===4?editor.getSnapshot().document:previous.acknowledged,options.registry,previous.loading,previous.loadingRead);
|
||||
if(previous.loading)assertLoadingChanges(commit.changes,previous.loading.version>=4?editor.getSnapshot().document:previous.acknowledged,options.registry,previous.loading,previous.loadingRead);
|
||||
if (!commit.operationIds.length) return previous;
|
||||
const ledger = createLedger(previous.ledger), resources = new Map(previous.resources);
|
||||
const firstSequence = next.operations.get(commit.operationIds[0])!.sequence - staged.length;
|
||||
@@ -428,7 +429,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
function completeLoading(document:Document,versions:ReadonlyMap<Id,number>,settings:{fresh?:boolean;sourceToken?:string}={}):Result{
|
||||
try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active');
|
||||
if(settings.fresh&&previous.loading.version>=3&&[...previous.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('loading_waiting_for_receipts');
|
||||
if(settings.fresh&&previous.loading.version===4){const complete=new Set(loadingQueryCoverage(root).completeCanvasIds),capture=previous.loadingRead!;
|
||||
if(settings.fresh&&(previous.loading.version===4||previous.loading.version===5)){const complete=new Set(loadingQueryCoverage(root).completeCanvasIds),capture=previous.loadingRead!;
|
||||
for(const [id,owner]of Object.entries(previous.loading.elementOwners))if(versions.get(owner)===capture.versions.get(owner)&&document.elements.get(id)?.ownerCanvasId!==owner)throw fault('canonical_version_mismatch',[owner]);
|
||||
for(const element of document.elements.values()){if(versions.get(element.ownerCanvasId)===capture.versions.get(element.ownerCanvasId)&&previous.loading.elementOwners[element.id]!==element.ownerCanvasId)throw fault('canonical_version_mismatch',[element.ownerCanvasId]);if(complete.has(element.ownerCanvasId)&&versions.get(element.ownerCanvasId)===previous.versions.get(element.ownerCanvasId)&&!equal(element,previous.acknowledged.elements.get(element.id)))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);}}
|
||||
const source=!settings.fresh&&previous.loading.version>=3?previous.loadingRead:undefined;validateLoadingCompletion(source?.document??previous.acknowledged,source?.versions??previous.versions,previous.loading,document,versions,options.registry,settings.fresh,settings.sourceToken);
|
||||
@@ -588,9 +589,10 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const result=editor.updateRuntime(previous=>{const ledger=createLedger(previous.ledger);ledger.add(freeze(intent),{});return Object.freeze({...previous,ledger:ledger.getSnapshot()});},root.rootRevision);
|
||||
if(result.status==='rejected')return result;queueMicrotask(()=>{if(!closing&&!closed)scheduler.notify([id]);});return{status:'applied',revision:root.revision,operationIds:[id]};
|
||||
}
|
||||
function assertImportWritable(){assertWritable(false);const loading=editor.getSnapshot().runtime.loading;if(loading&&loading.version!==5)throw fault('model_loading');}
|
||||
function getImport(id:Id){const job=editor.getSnapshot().runtime.imports?.get(id);if(!job)throw fault('import_missing',[id]);return job;}
|
||||
function publishImport(job:ImportJob,expectedGeneration:number){
|
||||
assertWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
|
||||
assertImportWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
|
||||
const result=editor.updateRuntime(previous=>{const imports=new Map(previous.imports??[]);if(imports.get(job.id)?.generation!==expectedGeneration)throw fault('stale_import_job');imports.set(job.id,job.version===3?job:freeze({...job,version:3 as const}));return Object.freeze({...previous,imports:readMap(imports)});});if(result.status==='rejected')throw fault(result.code,result.targets);
|
||||
}
|
||||
const deferredImportLoads=new Set<Id>();
|
||||
@@ -600,6 +602,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
if(!preparation||admitted.source?.kind!=='duplicate-snapshot')throw fault('invalid_copy_preparation');
|
||||
const check=()=>{const job=getImport(id);if(controller.signal.aborted||closing||closed||importHolds.size||job.status!=='preparing'||job.sourceFingerprint!==admitted.sourceFingerprint)throw fault('copy_aborted');return job;};
|
||||
await persist();check();const indexed=admitted.source.snapshotVersion===2?await openIndexedCopySnapshot(bytes,options.scope.projectId,options.registry,controller.signal):undefined,document=indexed?.document??await decodeCopySnapshot(bytes,options.scope.projectId,options.registry,controller.signal);
|
||||
validateLoadingCopySource(editor.getSnapshot(),admitted,document);
|
||||
if(copyRegistrySignature(document,options.registry)!==preparation.registrySignature)throw fault('copy_interpreter_changed');
|
||||
if(preparation.version===3||preparation.version===4){
|
||||
const state=await prepareDuplicateStream(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId},{sourceToken:admitted.source.sha256!,schemaToken:preparation.registrySignature,checkpoint:preparation.planner,topologyOnly:!!indexed},{signal:controller.signal,checkpoint:async state=>{
|
||||
@@ -619,7 +622,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:Math.max(previous.steps,steps),allocationIds:[...allocationIds]}}),job.generation);await persist();check();
|
||||
}});
|
||||
check();if(preparation.version===1&&cursor!==allocationIds.length)throw fault('copy_interpreter_changed');
|
||||
const encoded=await encodeDuplicateSource(document,options.registry,plan,controller.signal),sha256=await sourceDigest(encoded,controller.signal),job=check();assertWritable();assertNativeImportTarget(job,editor.getSnapshot().document);
|
||||
const encoded=await encodeDuplicateSource(document,options.registry,plan,controller.signal),sha256=await sourceDigest(encoded,controller.signal),job=check();assertImportWritable();assertNativeImportTarget(job,editor.getSnapshot().document);
|
||||
const permission=options.policy?.({type:'changes.apply',changes:plan.changes},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
|
||||
const fileId=createId(),runtime=editor.getSnapshot().runtime;if(!fileId||fileWrites.has(fileId)||runtime.resources.has(fileId)||[...(runtime.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict');
|
||||
const stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([old,id])=>[`element:${old}`,id]),...Object.entries(plan.canvasIds).map(([old,id])=>[`canvas:${old}`,id])]);
|
||||
@@ -649,13 +652,14 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
let copyDocument:Document|undefined,indexed:IndexedCopySnapshot|undefined;
|
||||
if(source.kind==='duplicate-snapshot'){if(preparationController.signal.aborted||importHolds.size||latest.status!=='preparing')return;const materialized=await materializeCopy(id,bytes,preparationController);job=materialized.job;source=job.source!;bytes=materialized.bytes;copyDocument=materialized.document;indexed=materialized.indexed;expectedFingerprint=job.sourceFingerprint;}
|
||||
if(source.kind==='duplicate-plan'&&source.snapshotVersion===2){indexed??=await openIndexedCopySnapshot(bytes,options.scope.projectId,options.registry,preparationController.signal);copyDocument=indexed.document;}
|
||||
if(source.kind==='duplicate-plan'&©Document)validateLoadingCopySource(editor.getSnapshot(),job,copyDocument);
|
||||
const importSource=source.kind==='duplicate-plan'?createDuplicatePlanSource(copyDocument??await decodeCopySnapshot(bytes,options.scope.projectId,options.registry,preparationController.signal),options.registry,source.plan!,job.sourceFingerprint,indexed?.readElement):createNdjsonImportSource({bytes,fingerprint:job.sourceFingerprint});
|
||||
if(closing||closed||preparationController.signal.aborted||getImport(id).generation!==job.generation)return;
|
||||
if(importHolds.size){deferredImportLoads.add(id);return;}
|
||||
const runner=createImportRunner({optimisticLocal:true,awaitDurabilityBudget:stage=>pending.size>=32||stage!=='parsed'&&persistenceFailed?persist():undefined,source:importSource,getJob:()=>getImport(id),createId,
|
||||
publish:publishImport,prepare:(line,job,reserve)=>['duplicate-ndjson','duplicate-plan'].includes(job.source?.kind??'')?prepareDuplicateImport(line,job,reserve,options.registry):prepareNativeImport(line,job,reserve,editor.getSnapshot().document,options.registry),persist,onError:report,
|
||||
commit(units,next,expected){
|
||||
assertWritable();if(importing)throw fault('reentrant_dispatch');const current=getImport(id);if(current.generation!==expected)throw fault('stale_import_job');
|
||||
assertImportWritable();if(importing)throw fault('reentrant_dispatch');const current=getImport(id);if(current.generation!==expected)throw fault('stale_import_job');
|
||||
try{assertNativeImportTarget(current,editor.getSnapshot().document);for(const unit of units){assertNativeImportDependencies(current,editor.getSnapshot().document,unit.dependencies);if(['duplicate-ndjson','duplicate-plan'].includes(current.source?.kind??''))assertDuplicateExternal({cursor:0,nextCursor:1,key:unit.key,value:unit.guard},current,editor.getSnapshot().document,options.registry);}}catch(error){return reject(error);}
|
||||
importing={job:next,expectedGeneration:expected};
|
||||
try{return editor.transaction(tx=>{for(const unit of units)for(const change of unit.changes){if(change.kind==='element'&&change.before===null&&change.after)tx.dispatch({type:'element.create',element:change.after});else if(change.kind==='canvas'&&change.before===null&&change.after)tx.dispatch({type:'canvas.create',canvas:change.after});else throw fault('invalid_native_import_unit');}},{label:['duplicate-ndjson','duplicate-plan'].includes(current.source?.kind??'')?'Duplicate selection':'Import canvas content',groupId:`import:${id}`});}finally{importing=undefined;}
|
||||
@@ -668,42 +672,45 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
}
|
||||
const imports=Object.freeze({
|
||||
getJobs:()=>readMap(editor.getSnapshot().runtime.imports??new Map()),
|
||||
canStart(targetCanvasId:Id){try{assertImportWritable();const snapshot=editor.getSnapshot(),permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);if(!snapshot.document.canvases.has(targetCanvasId))throw fault('canvas_not_loaded',[targetCanvasId]);loadingImportAdmission(snapshot,targetCanvasId);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}},
|
||||
canDuplicate(selection:readonly Target[],settings:{viewCanvasId?:Id}={}){try{assertImportWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]);const snapshot=editor.getSnapshot(),permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);if(!selection.length)throw fault('empty_copy_selection');for(const selected of selection){const element=snapshot.document.elements.get(selected.id);if(!element||element.incarnation!==selected.incarnation)throw fault('stale_incarnation',[selected.id]);}const target=settings.viewCanvasId??snapshot.document.elements.get(selection[0].id)!.ownerCanvasId;if(!snapshot.document.canvases.has(target))throw fault('canvas_not_loaded',[target]);loadingImportAdmission(snapshot,target,selection);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}},
|
||||
openSource:openImportSource,
|
||||
startNdjson(bytes:Blob,targetCanvasId:Id):Id{
|
||||
assertWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
|
||||
assertImportWritable();const loadingAdmission=loadingImportAdmission(editor.getSnapshot(),targetCanvasId);const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
|
||||
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||current.orphanFiles?.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict');
|
||||
const entry=canvas.parent?editor.getSnapshot().document.elements.get(canvas.parent.entryElementId)!:undefined;
|
||||
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const});
|
||||
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{...(loadingAdmission?{loadingAdmission}:{}),kind:'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const});
|
||||
fileWrites.set(fileId,bytes);
|
||||
const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
|
||||
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;
|
||||
},
|
||||
async duplicate(selection:readonly Target[],settings:{readonly worldOffset?:Point;readonly signal?:AbortSignal;readonly viewCanvasId?:Id}={}):Promise<Id>{
|
||||
assertWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]);
|
||||
assertImportWritable();if(collectionReservations.size)throw fault('resource_collection_in_progress',[...collectionReservations]);
|
||||
const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);
|
||||
if([...editor.getSnapshot().runtime.resources.values(),...(editor.getSnapshot().runtime.orphanFiles?.values()??[])].some(file=>file.retirement&&file.retirement.purgedAt===undefined)){
|
||||
const reset=editor.updateRuntime(previous=>{const resources=new Map(previous.resources);for(const [id,resource]of resources)if(resource.retirement&&resource.retirement.purgedAt===undefined){const {retirement:_,...kept}=resource;resources.set(id,freeze(kept));}const files=new Map(previous.orphanFiles);for(const [id,file]of files)if(file.retirement&&file.retirement.purgedAt===undefined){const {retirement:_,...kept}=file;files.set(id,freeze(kept));}return Object.freeze({...previous,resources:readMap(resources),...(previous.orphanFiles?{orphanFiles:readMap(files)}:{})});});if(reset.status==='rejected')throw fault(reset.code,reset.targets);
|
||||
}
|
||||
const captured=editor.getSnapshot(),target=settings.viewCanvasId??(selection[0]?captured.document.elements.get(selection[0].id)?.ownerCanvasId:undefined),signal=settings.signal,controller=new AbortController(),abort=()=>controller.abort();
|
||||
const loadingAdmission=target?loadingImportAdmission(captured,target,selection):undefined;
|
||||
const targets=freeze(clone(selection)),offset=freeze(clone(settings.worldOffset??{x:32,y:32}));
|
||||
for(const selected of targets){const element=captured.document.elements.get(selected.id);if(!element||element.incarnation!==selected.incarnation)throw fault('stale_incarnation',[selected.id]);}if(![offset.x,offset.y].every(Number.isFinite))throw fault('invalid_copy_offset');
|
||||
signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)controller.abort();
|
||||
const task=(async()=>{
|
||||
if(!target||!targets.length)throw fault('empty_copy_selection');
|
||||
const bytes=await encodeIndexedCopySnapshot(captured.document,controller.signal);assertWritable();if(controller.signal.aborted)throw fault('copy_aborted');
|
||||
const bytes=await encodeIndexedCopySnapshot(captured.document,controller.signal);assertImportWritable();if(controller.signal.aborted)throw fault('copy_aborted');
|
||||
const canvas=captured.document.canvases.get(target);if(!canvas)throw fault('canvas_not_loaded',[target]);
|
||||
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;
|
||||
if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||current.orphanFiles?.has(fileId)||fileWrites.has(fileId)||importFiles(current.imports).has(fileId))throw fault('import_identity_conflict');
|
||||
const entry=canvas.parent?captured.document.elements.get(canvas.parent.entryElementId)!:undefined;
|
||||
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'duplicate-snapshot',snapshotVersion:2,name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:4 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}});
|
||||
const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{...(loadingAdmission?{loadingAdmission}:{}),kind:'duplicate-snapshot',snapshotVersion:2,name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:[]},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'preparing' as const,preparation:{version:4 as const,registrySignature:copyRegistrySignature(captured.document,options.registry),selection:targets,worldOffset:offset,allocationIds:[],steps:0}});
|
||||
fileWrites.set(fileId,bytes);const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});});
|
||||
if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id;
|
||||
})();
|
||||
copyPreparations.set(controller,task);void task.finally(()=>{copyPreparations.delete(controller);signal?.removeEventListener('abort',abort);}).catch(()=>{});return task;
|
||||
},
|
||||
async pause(id:Id){assertWritable();const runner=importRunners.get(id);if(runner)await runner.pause();else{const job=getImport(id);if(['running','preparing'].includes(job.status)){preparationControllers.get(id)?.abort();publishImport(freeze({...job,generation:job.generation+1,status:'paused'}),job.generation);await persist();}}},
|
||||
async resume(id:Id){assertWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(['running','preparing'].includes(job.status)){await loadImport(id);return;}await importLoads.get(id);await importRunners.get(id)?.settle();await persist();await scheduler.resume();assertWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:current.source?.kind==='duplicate-snapshot'?'preparing' as const:'running' as const};delete next.error;publishImport(freeze(next),current.generation);await loadImport(id);},
|
||||
async cancel(id:Id){assertWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','preparing','paused'].includes(job.status)){preparationControllers.get(id)?.abort();const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(freeze(next),job.generation);await persist();}}},
|
||||
async pause(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.pause();else{const job=getImport(id);if(['running','preparing'].includes(job.status)){preparationControllers.get(id)?.abort();publishImport(freeze({...job,generation:job.generation+1,status:'paused'}),job.generation);await persist();}}},
|
||||
async resume(id:Id){assertImportWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(['running','preparing'].includes(job.status)){await loadImport(id);return;}await importLoads.get(id);await importRunners.get(id)?.settle();await persist();await scheduler.resume();assertImportWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:current.source?.kind==='duplicate-snapshot'?'preparing' as const:'running' as const};delete next.error;publishImport(freeze(next),current.generation);await loadImport(id);},
|
||||
async cancel(id:Id){assertImportWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','preparing','paused'].includes(job.status)){preparationControllers.get(id)?.abort();const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(freeze(next),job.generation);await persist();}}},
|
||||
});
|
||||
scheduler.start(); prepareDigests();prepareImages(); runTracker.notify([...initial.runs.keys()]);
|
||||
for(const job of initial.imports?.values()??[])if(['running','preparing'].includes(job.status))void loadImport(job.id);
|
||||
|
||||
@@ -37,7 +37,7 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
|
||||
const preview=settings.onPreview?createModelPreview(options.registry):undefined;let seen=0,previewFailed=false;let declaredCanvasIds:readonly Id[]=[];
|
||||
const onUpdate=(state:import('./loader.ts').LoadSnapshot<CanonicalReadUpdate>)=>{
|
||||
if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return;
|
||||
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value){if(chunk.sequence===0)declaredCanvasIds=Object.freeze(chunk.value.canvases.map(canvas=>canvas.id));settings.onPreview?.(Object.freeze({...value,versions:readMap(new Map(Object.entries(chunk.serverVersions))),loading:Object.freeze({...(value.canvasElementCounts?value.elementOwners?{version:4 as const,canvasElementCounts:value.canvasElementCounts,elementOwners:value.elementOwners}:{version:3 as const,canvasElementCounts:value.canvasElementCounts}:{version:1 as const}),sourceToken:chunk.snapshotToken,declaredCanvasIds,receivedElements:value.receivedElements,totalElements:value.totalElements})}));}}}
|
||||
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value){if(chunk.sequence===0)declaredCanvasIds=Object.freeze(chunk.value.canvases.map(canvas=>canvas.id));settings.onPreview?.(Object.freeze({...value,versions:readMap(new Map(Object.entries(chunk.serverVersions))),loading:Object.freeze({...(value.canvasElementCounts?value.elementOwners?{version:5 as const,canvasElementCounts:value.canvasElementCounts,elementOwners:value.elementOwners,sourceCanvases:value.sourceCanvases!}:{version:3 as const,canvasElementCounts:value.canvasElementCounts}:{version:1 as const}),sourceToken:chunk.snapshotToken,declaredCanvasIds,receivedElements:value.receivedElements,totalElements:value.totalElements})}));}}}
|
||||
catch(error){previewFailed=true;try{options.onObserverError?.(error);}catch{}}
|
||||
};
|
||||
const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate});
|
||||
|
||||
@@ -9,7 +9,8 @@ export type ImportSource = { readonly fingerprint: string; readonly parserVersio
|
||||
export type ImportUnit = { readonly key: string; readonly dependencies: readonly string[]; readonly changes: readonly Change[]; readonly guard?: Json };
|
||||
export type ImportLocator = { readonly key: string; readonly cursor: number; readonly nextCursor: number; readonly dependencies: readonly string[]; readonly digest: string; readonly byteLength: number; readonly changeCount: number };
|
||||
export type ImportFailure = { readonly key: string; readonly cursor: number; readonly code: string; readonly dependencies?: readonly string[] };
|
||||
export type NativeImportSource = { readonly kind: 'native-ndjson' | 'duplicate-ndjson' | 'duplicate-snapshot' | 'duplicate-plan'; readonly retirement?:FileRetirement;readonly previousFiles?:readonly PreviousImportFile[];readonly snapshotVersion?:2;readonly plan?:DuplicateCheckpoint; readonly copy?: {readonly sourceRevision:number; readonly roots:readonly Id[]}; readonly integrityVersion?:1;readonly sha256?:string; readonly name?:string; readonly fileId: Id; readonly byteLength: number; readonly targetCanvasId: Id; readonly targetEntry?: {readonly id:Id;readonly incarnation:number} };
|
||||
export type LoadingImportAdmission={readonly version:1;readonly sourceToken:string;readonly operationSequence:number;readonly requiredCanvasIds:readonly Id[]};
|
||||
export type NativeImportSource = { readonly loadingAdmission?:LoadingImportAdmission; readonly kind: 'native-ndjson' | 'duplicate-ndjson' | 'duplicate-snapshot' | 'duplicate-plan'; readonly retirement?:FileRetirement;readonly previousFiles?:readonly PreviousImportFile[];readonly snapshotVersion?:2;readonly plan?:DuplicateCheckpoint; readonly copy?: {readonly sourceRevision:number; readonly roots:readonly Id[]}; readonly integrityVersion?:1;readonly sha256?:string; readonly name?:string; readonly fileId: Id; readonly byteLength: number; readonly targetCanvasId: Id; readonly targetEntry?: {readonly id:Id;readonly incarnation:number} };
|
||||
export type ImportBinding = {readonly kind:'element';readonly incarnation:number}|{readonly kind:'canvas';readonly entryElementId?:Id};
|
||||
export type ImportJob = {
|
||||
readonly version: 1 | 2 | 3; readonly id: Id; readonly projectId: Id; readonly sourceFingerprint: string; readonly parserVersion: number;
|
||||
@@ -29,6 +30,7 @@ export function validateImportJob(job: ImportJob): void {
|
||||
createImportJob(job);
|
||||
if (!Number.isSafeInteger(job.generation) || job.generation<0 || !Number.isSafeInteger(job.cursor) || job.cursor<0 || typeof job.eof!=='boolean' || !['preparing','paused','running','completed','partial','cancelled'].includes(job.status)) throw fault('invalid_import_job');
|
||||
if(job.source&&(!['native-ndjson','duplicate-ndjson','duplicate-snapshot','duplicate-plan'].includes(job.source.kind)||['duplicate-ndjson','duplicate-plan'].includes(job.source.kind)&&(!job.source.copy||!Number.isSafeInteger(job.source.copy.sourceRevision)||job.source.copy.sourceRevision<0||!Array.isArray(job.source.copy.roots)||!job.source.copy.roots.length||job.source.copy.roots.some(id=>typeof id!=='string'||!id))||job.source.name!==undefined&&(typeof job.source.name!=='string'||job.source.name.length>255)||!job.source.fileId||!job.source.targetCanvasId||!Number.isSafeInteger(job.source.byteLength)||job.source.byteLength<0||job.source.targetEntry&&(!job.source.targetEntry.id||!Number.isSafeInteger(job.source.targetEntry.incarnation)||job.source.targetEntry.incarnation<1)))throw fault('invalid_import_source');
|
||||
if(job.source?.loadingAdmission){const proof=job.source.loadingAdmission;if(proof.version!==1||typeof proof.sourceToken!=='string'||!proof.sourceToken||proof.sourceToken.length>256||!Number.isSafeInteger(proof.operationSequence)||proof.operationSequence<0||!Array.isArray(proof.requiredCanvasIds)||!proof.requiredCanvasIds.length||proof.requiredCanvasIds.some(id=>typeof id!=='string'||!id)||new Set(proof.requiredCanvasIds).size!==proof.requiredCanvasIds.length)throw fault('loading_import_interpreter_unavailable');}
|
||||
if(job.source){
|
||||
validateImportFileRetention(job.source,job.status);
|
||||
if(job.source.snapshotVersion!==undefined&&(job.source.snapshotVersion!==2||!['duplicate-snapshot','duplicate-plan'].includes(job.source.kind)))throw fault('copy_snapshot_interpreter_unavailable');
|
||||
|
||||
@@ -11,7 +11,7 @@ export type { AssetPort, ResourceRecord, ResourceHandle, StagedFile, StoredAsset
|
||||
export type { RunPort, RunRecord, RunPage, RunEvent, RunStatus, RunModel } from './runs.ts';
|
||||
export type { FrozenRequest, LedgerSnapshot, OperationRecord, Presence } from './ledger.ts';
|
||||
export type { Clock, OperationStatus, CompiledRequest } from './scheduler.ts';
|
||||
export type { ImportJob, ImportSource, ImportLine, ImportUnit, ImportFailure, NativeImportSource } from './imports.ts';
|
||||
export type { LoadingImportAdmission,ImportJob, ImportSource, ImportLine, ImportUnit, ImportFailure, NativeImportSource } from './imports.ts';
|
||||
export type { RecoveryChoice, ConflictPlan, RecoveryAudit } from './conflicts.ts';
|
||||
export { encodeRecovery, decodeRecovery } from './recovery.ts';
|
||||
export { createScopedLoader, loadQueryKey } from './loader.ts';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {CanvasRecord,Document,Id,Target} from '../canvas-core/types.ts';
|
||||
import {fault,freeze,equal} from '../canvas-core/values.ts';
|
||||
import type {EngineSnapshot} from './contracts.ts';
|
||||
import {validateImportJob,type LoadingImportAdmission,type ImportJob} from './imports.ts';
|
||||
import {applyChanges} from '../canvas-core/document.ts';
|
||||
import {copyTopology} from './copy-snapshot-index.ts';
|
||||
import {structuralRequestScope} from './loading.ts';
|
||||
import {ownerReady} from './loading-structure.ts';
|
||||
|
||||
const relations=new WeakMap<Document,{members:Map<Id,Id[]>;children:Map<Id,Id[]>}>();
|
||||
function index(document:Document){let value=relations.get(document);if(!value){const members=new Map<Id,Id[]>(),children=new Map<Id,Id[]>();for(const element of document.elements.values()){const ids=members.get(element.ownerCanvasId)??[];ids.push(element.id);members.set(element.ownerCanvasId,ids);}for(const canvas of document.canvases.values())if(canvas.parent){const ids=children.get(canvas.parent.entryElementId)??[];ids.push(canvas.id);children.set(canvas.parent.entryElementId,ids);}value={members,children};relations.set(document,value);}return value;}
|
||||
/** The caller captures this proof with the same document that becomes source bytes. */
|
||||
export function loadingImportAdmission(snapshot:EngineSnapshot,target:Id,selection?:readonly Target[]):LoadingImportAdmission|undefined {
|
||||
const state=snapshot.runtime.loading;if(!state)return;
|
||||
if(state.version!==5||!snapshot.runtime.loadingRead)throw fault('model_loading');
|
||||
const source=snapshot.runtime.loadingRead,document=snapshot.document,required=new Set<Id>([target]);
|
||||
const requireScope=(id:Id)=>{required.add(id);if(!ownerReady(state,source,document,id))throw fault('loading_scope_incomplete',[id]);};requireScope(target);
|
||||
if(selection){
|
||||
if(!selection.length)throw fault('empty_copy_selection');const tables=index(document),pending=selection.map(target=>{const element=document.elements.get(target.id);if(!element||element.incarnation!==target.incarnation)throw fault('stale_incarnation',[target.id]);return target.id;}),copied=new Set<Id>(),canvases=new Map<Id,CanvasRecord>(document.canvases);
|
||||
// Missing captured children cannot be mistaken for an empty/leaf selection.
|
||||
const missingChildren=new Map<Id,Id[]>();for(const canvas of state.sourceCanvases)if(canvas.parent&&!source.document.canvases.has(canvas.id)&&!document.canvases.has(canvas.id)){canvases.set(canvas.id,canvas);const ids=missingChildren.get(canvas.parent.entryElementId)??[];ids.push(canvas.id);missingChildren.set(canvas.parent.entryElementId,ids);}
|
||||
while(pending.length){const id=pending.pop()!;if(copied.has(id))continue;copied.add(id);const element=document.elements.get(id);if(!element)throw fault('loading_scope_incomplete',[id]);requireScope(element.ownerCanvasId);
|
||||
for(const child of [...(tables.children.get(id)??[]),...(missingChildren.get(id)??[])]){requireScope(child);pending.push(...(tables.members.get(child)??[]));}
|
||||
}
|
||||
for(const id of copied){const element=document.elements.get(id)!;if(Object.hasOwn(state.elementOwners,id)){
|
||||
let canvas=canvases.get(element.ownerCanvasId);const seen=new Set<Id>();while(canvas){if(seen.has(canvas.id))throw fault('invalid_model_topology');seen.add(canvas.id);requireScope(canvas.id);canvas=canvas.parent?canvases.get(canvas.parent.canvasId):undefined;}
|
||||
}}
|
||||
// Edges involving born objects are entirely local to this captured namespace.
|
||||
for(const element of document.elements.values())if(element.structure==='connection'&&copied.has(element.target.elementId)&&!copied.has(element.source.elementId))requireScope(element.ownerCanvasId);
|
||||
}
|
||||
let operationSequence=0;for(const record of snapshot.runtime.ledger.operations.values())operationSequence=Math.max(operationSequence,record.intent.sequence);
|
||||
return freeze({version:1 as const,sourceToken:state.sourceToken,operationSequence,requiredCanvasIds:[...required].sort()});
|
||||
}
|
||||
export function validateLoadingImportAdmissions(runtime:EngineSnapshot['runtime']):void {
|
||||
const state=runtime.loading;if(!state||state.version!==5)return;
|
||||
let maxSequence=0;for(const record of runtime.ledger.operations.values())maxSequence=Math.max(maxSequence,record.intent.sequence);
|
||||
for(const job of runtime.imports?.values()??[]){validateImportJob(job);if(job.projectId!==runtime.acknowledged.projectId)throw fault('invalid_loading_import',[job.id]);const proof=job.source?.loadingAdmission;if(!proof||proof.version!==1||proof.sourceToken!==state.sourceToken||proof.operationSequence>maxSequence||!proof.requiredCanvasIds.includes(job.source!.targetCanvasId)||proof.requiredCanvasIds.some(id=>!structuralRequestScope(runtime,runtime.loadingRead!,id)))throw fault('invalid_loading_import',[job.id]);}
|
||||
}
|
||||
|
||||
/** Revalidate the frozen copy topology before planning or resuming its source.
|
||||
* Later source chunks may add unrelated owners, so compare only the closed cut. */
|
||||
export function validateLoadingCopySource(snapshot:EngineSnapshot,job:ImportJob,document:Document):void {
|
||||
const state=snapshot.runtime.loading,proof=job.source?.loadingAdmission;if(!state||state.version!==5||!proof)return;
|
||||
const source=snapshot.runtime.loadingRead!;let expected=source.document;
|
||||
for(const record of [...snapshot.runtime.ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence))if(record.intent.sequence<=proof.operationSequence)expected=applyChanges(expected,record.intent.changes,false);
|
||||
const selection=job.preparation?.selection??job.source?.plan?.selection;if(!selection)throw fault('invalid_loading_import',[job.id]);
|
||||
const required=loadingImportAdmission({...snapshot,document:expected},job.source!.targetCanvasId,selection)!;
|
||||
if(!equal(required.requiredCanvasIds,proof.requiredCanvasIds))throw fault('loading_copy_scope_changed',[job.id]);
|
||||
for(const id of proof.requiredCanvasIds){if(!equal(expected.canvases.get(id),document.canvases.get(id)))throw fault('loading_copy_scope_changed',[id]);
|
||||
const wanted=Object.fromEntries([...expected.elements.values()].filter(element=>element.ownerCanvasId===id).map(element=>[element.id,copyTopology(element)])),actual=Object.fromEntries([...document.elements.values()].filter(element=>element.ownerCanvasId===id).map(element=>[element.id,copyTopology(element)]));if(!equal(wanted,actual))throw fault('loading_copy_scope_changed',[id]);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const counts=new WeakMap<Document,ReadonlyMap<Id,number>>();
|
||||
function membership(document:Document){let result=counts.get(document);if(!result){const next=new Map<Id,number>();for(const element of document.elements.values())next.set(element.ownerCanvasId,(next.get(element.ownerCanvasId)??0)+1);result=next;counts.set(document,result);}return result;}
|
||||
export function sourceClosed(state:LoadingState,source:Capture){return source.document.elements.size===state.totalElements&&source.document.canvases.size===state.declaredCanvasIds.length;}
|
||||
export function ownerReady(state:LoadingState,source:Capture,document:Document,id:Id){return document.canvases.has(id)&&(!source.versions.has(id)||state.version!==1&&(membership(source.document).get(id)??0)===state.canvasElementCounts[id]);}
|
||||
function born(state:LoadingState,id:Id){return state.version===4&&!Object.hasOwn(state.elementOwners,id);}
|
||||
function born(state:LoadingState,id:Id){return (state.version===4||state.version===5)&&!Object.hasOwn(state.elementOwners,id);}
|
||||
function safeData(data:ElementRecord['data'],registry:Registry,component:Id,document:Document,source?:Document,prior?:ElementRecord['data']){
|
||||
const definition=registry.components.get(component);if(!definition?.copy)return false;
|
||||
for(const key of ['ai','taskId','runId'])if(data[key]!==undefined&&data[key]!==null&&!equal(data[key],prior?.[key]))return false;
|
||||
@@ -21,7 +21,7 @@ function safeData(data:ElementRecord['data'],registry:Registry,component:Id,docu
|
||||
}
|
||||
function references(document:Document,registry:Registry){for(const element of document.elements.values())if(registry.resolve(element)==='known'){const schema=registry.components.get(element.componentId)?.copy;for(const reference of schema?.references??[])for(const id of copyReferenceIds(element.data,reference))if(!(reference.target==='element'?document.elements:document.canvases).has(id))throw fault('loading_reference_incomplete',[element.id,id]);}}
|
||||
export function structuralCommand(command:Command,document:Document,registry:Registry,state:LoadingState,source:Capture):Availability|undefined {
|
||||
if(state.version!==4)return;
|
||||
if(state.version<4)return;
|
||||
const deny:Availability={enabled:false,code:'model_loading',targets:[]},yes:Availability={enabled:true};
|
||||
if(command.type==='element.create'){const element=command.element;return born(state,element.id)&&!source.document.tombstones.has(element.id)&&ownerReady(state,source,document,element.ownerCanvasId)&®istry.resolve(element)==='known'&&safeData(element.data,registry,element.componentId,document,source.document)?yes:deny;}
|
||||
if(command.type==='canvas.create'){const canvas=command.canvas,parent=canvas.parent;return !source.versions.has(canvas.id)&&!!parent&&born(state,parent.entryElementId)&&ownerReady(state,source,document,parent.canvasId)?yes:deny;}
|
||||
@@ -32,7 +32,7 @@ export function structuralCommand(command:Command,document:Document,registry:Reg
|
||||
}
|
||||
/** Actual changes, not a command name, establish the mutation's required closure. */
|
||||
export function assertStructuralChanges(changes:readonly Change[],document:Document,registry:Registry,state:LoadingState,source:Capture):Document {
|
||||
if(state.version!==4)throw fault('model_loading');
|
||||
if(state.version<4)throw fault('model_loading');
|
||||
let cursor=document;const closed=sourceClosed(state,source);
|
||||
for(const change of changes){
|
||||
if(change.kind==='canvas'){
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {validateLoadingImportAdmissions} from './loading-import.ts';
|
||||
import {validateStructuralIntent,structuralCommand,assertStructuralChanges,appliedSource,ownerReady} from './loading-structure.ts';
|
||||
import {validateElementOwners,type ElementOwners,validateCanvasElementCounts,type CanvasElementCounts} from './scope-counts.ts';
|
||||
import type {Availability,Change,Command,Document,Id,Registry} from '../canvas-core/types.ts';
|
||||
import {validateCanvasTopology,validateElementOwners,type ElementOwners,validateCanvasElementCounts,type CanvasElementCounts} from './scope-counts.ts';
|
||||
import type {Availability,Change,Command,Document,Id,Registry,CanvasRecord} from '../canvas-core/types.ts';
|
||||
import {createDocument,validateDocument} from '../canvas-core/document.ts';
|
||||
import {equal,fault,readSet} from '../canvas-core/values.ts';
|
||||
import type {CanonicalProposal,RuntimeState} from './contracts.ts';
|
||||
@@ -8,14 +9,16 @@ import type {CanonicalProposal,RuntimeState} from './contracts.ts';
|
||||
/** Durable marker for a verified read prefix. Versions 1/2 hold all writes;
|
||||
* version 3 admits certified scopes with a retained read baseline. No HTTP cursor. */
|
||||
type LoadingBase={readonly sourceToken:string;readonly declaredCanvasIds:readonly Id[];readonly receivedElements:number;readonly totalElements:number};
|
||||
export type LoadingState=LoadingBase&({readonly version:1;readonly canvasElementCounts?:never;readonly elementOwners?:never}|{readonly version:2|3;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners?:never}|{readonly version:4;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners});
|
||||
export type LoadingState=LoadingBase&({readonly version:1;readonly canvasElementCounts?:never;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:2|3;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners?:never;readonly sourceCanvases?:never}|{readonly version:4;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases?:never}|{readonly version:5;readonly canvasElementCounts:CanvasElementCounts;readonly elementOwners:ElementOwners;readonly sourceCanvases:readonly CanvasRecord[]});
|
||||
export function validateLoadingState(value:LoadingState,document:Document,versions:ReadonlyMap<Id,number>):void{
|
||||
if(!value||![1,2,3,4].includes(value.version))throw fault('loading_interpreter_unavailable');
|
||||
if(!value||![1,2,3,4,5].includes(value.version))throw fault('loading_interpreter_unavailable');
|
||||
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]);
|
||||
if(typeof value.sourceToken!=='string'||!value.sourceToken||value.sourceToken.length>256||!Array.isArray(value.declaredCanvasIds)||!value.declaredCanvasIds.length||value.declaredCanvasIds.some(id=>typeof id!=='string'||!id||!versions.has(id))||new Set(value.declaredCanvasIds).size!==value.declaredCanvasIds.length||!value.declaredCanvasIds.includes(document.projectId)||[...document.canvases.keys()].some(id=>!value.declaredCanvasIds.includes(id))||![value.receivedElements,value.totalElements].every(value=>Number.isSafeInteger(value)&&value>=0)||value.receivedElements>value.totalElements||document.elements.size>value.receivedElements)throw fault('invalid_loading_state');
|
||||
if(value.version===1&&value.canvasElementCounts!==undefined)throw fault('invalid_loading_state');
|
||||
if(value.version!==4&&value.elementOwners!==undefined)throw fault('invalid_loading_state');
|
||||
if(value.version===4){validateElementOwners(value.elementOwners,value.canvasElementCounts,value.totalElements);for(const element of document.elements.values())if(!Object.hasOwn(value.elementOwners,element.id)||value.elementOwners[element.id]!==element.ownerCanvasId)throw fault('invalid_model_identity');}
|
||||
if(value.version<4&&value.elementOwners!==undefined)throw fault('invalid_loading_state');
|
||||
if(value.version===4||value.version===5){validateElementOwners(value.elementOwners,value.canvasElementCounts,value.totalElements);for(const element of document.elements.values())if(!Object.hasOwn(value.elementOwners,element.id)||value.elementOwners[element.id]!==element.ownerCanvasId)throw fault('invalid_model_identity');}
|
||||
if(value.version!==5&&value.sourceCanvases!==undefined)throw fault('invalid_loading_state');
|
||||
if(value.version===5){validateCanvasTopology(value.sourceCanvases,value.elementOwners,document.projectId);if(!equal(value.sourceCanvases.map(canvas=>canvas.id).sort(),[...value.declaredCanvasIds].sort())||[...document.canvases].some(([id,record])=>!equal(record,value.sourceCanvases.find(canvas=>canvas.id===id))))throw fault('invalid_model_topology');}
|
||||
if(value.version!==1){validateCanvasElementCounts(value.canvasElementCounts,value.declaredCanvasIds,value.totalElements);const counts=new Map<Id,number>();for(const element of document.elements.values()){const count=(counts.get(element.ownerCanvasId)??0)+1;if(count>value.canvasElementCounts[element.ownerCanvasId])throw fault('invalid_model_coverage');counts.set(element.ownerCanvasId,count);}}
|
||||
}
|
||||
const denied:Availability=Object.freeze({enabled:false,code:'model_loading',targets:[]});
|
||||
@@ -28,7 +31,7 @@ function pathAllowed(id:Id,document:Document,registry:Registry,loading?:LoadingS
|
||||
return (directCounts(base).get(element.ownerCanvasId)??0)===loading.canvasElementCounts[element.ownerCanvasId];
|
||||
}
|
||||
export function loadingCommand(command:Command,document:Document,registry:Registry,loading?:LoadingState,base=document,source?:CanonicalProposal):Availability{
|
||||
if(loading?.version===4&&source){const scoped=structuralCommand(command,document,registry,loading,source);if(scoped)return scoped;}
|
||||
if(loading&&(loading.version===4||loading.version===5)&&source){const scoped=structuralCommand(command,document,registry,loading,source);if(scoped)return scoped;}
|
||||
if(command.type==='connection.path')return pathAllowed(command.target.id,document,registry,loading,base)?{enabled:true}:denied;
|
||||
if(command.type!=='element.data'&&command.type!=='element.geometry')return denied;
|
||||
const element=document.elements.get(command.target.id);if(!element||registry.resolve(element)!=='known')return denied;
|
||||
@@ -38,7 +41,7 @@ export function loadingCommand(command:Command,document:Document,registry:Regist
|
||||
/** Check actual changes too: a feature handler cannot evade command admission by
|
||||
* modifying a protected reference field as a side effect of an allowed edit. */
|
||||
export function assertLoadingChanges(changes:readonly Change[],document:Document,registry:Registry,loading?:LoadingState,source?:CanonicalProposal):void{
|
||||
if(loading?.version===4&&source){assertStructuralChanges(changes,document,registry,loading,source);return;}
|
||||
if(loading&&(loading.version===4||loading.version===5)&&source){assertStructuralChanges(changes,document,registry,loading,source);return;}
|
||||
for(const change of changes){
|
||||
if(change.kind!=='field')throw fault('model_loading');
|
||||
const element=document.elements.get(change.target.id);
|
||||
@@ -51,16 +54,17 @@ export function validateLoadingRuntime(runtime:RuntimeState,registry:Registry):v
|
||||
if(runtime.loading.version>=3&&!source||runtime.loading.version<3&&runtime.loadingRead)throw fault('invalid_loading_state');
|
||||
validateLoadingState(runtime.loading,source?.document??runtime.acknowledged,source?.versions??runtime.versions);
|
||||
if(source)validateLoadingRead(runtime,registry);
|
||||
if(runtime.loading.version<3&&runtime.ledger.requests.size||runtime.resources.size||runtime.runs.size||runtime.imports?.size||runtime.orphanFiles?.size||runtime.recoveries.size)throw fault('invalid_loading_state');
|
||||
if(runtime.loading.version<3&&runtime.ledger.requests.size||runtime.resources.size||runtime.runs.size||runtime.loading.version<5&&runtime.imports?.size||runtime.orphanFiles?.size||runtime.recoveries.size)throw fault('invalid_loading_state');
|
||||
validateLoadingImportAdmissions(runtime);
|
||||
let logical=source?.document??runtime.acknowledged;
|
||||
for(const record of [...runtime.ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){
|
||||
if(record.intent.operationType!=='document.change'||runtime.loading.version<3&&(record.requestId||record.resolution||!['pending','blocked'].includes(record.phase)))throw fault('invalid_loading_state');
|
||||
if(runtime.loading.version===4){logical=validateStructuralIntent(record.intent,logical,registry,runtime.loading,source!);}else assertLoadingChanges(record.intent.changes,source?.document??runtime.acknowledged,registry,runtime.loading);
|
||||
if(runtime.loading.version>=4){logical=validateStructuralIntent(record.intent,logical,registry,runtime.loading,source!);}else assertLoadingChanges(record.intent.changes,source?.document??runtime.acknowledged,registry,runtime.loading);
|
||||
}
|
||||
}
|
||||
export function validatePrefixExtension(previous:Document,previousVersions:ReadonlyMap<Id,number>,state:LoadingState,next:Document,versions:ReadonlyMap<Id,number>,nextState:LoadingState,registry:Registry):void{
|
||||
validateDocument(next,registry);validateLoadingState(nextState,next,versions);
|
||||
if(next.projectId!==previous.projectId||state.sourceToken!==nextState.sourceToken||state.version!==nextState.version||!equal(state.canvasElementCounts,nextState.canvasElementCounts)||!equal(state.elementOwners,nextState.elementOwners)||state.totalElements!==nextState.totalElements||nextState.receivedElements<state.receivedElements||!equal(state.declaredCanvasIds,nextState.declaredCanvasIds)||!equal(Object.fromEntries(previousVersions),Object.fromEntries(versions))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))||[...previous.canvases].some(([id,record])=>!equal(record,next.canvases.get(id)))||[...previous.elements].some(([id,record])=>!equal(record,next.elements.get(id))))throw fault('loading_source_mismatch');
|
||||
if(next.projectId!==previous.projectId||state.sourceToken!==nextState.sourceToken||state.version!==nextState.version||!equal(state.canvasElementCounts,nextState.canvasElementCounts)||!equal(state.elementOwners,nextState.elementOwners)||!equal(state.sourceCanvases,nextState.sourceCanvases)||state.totalElements!==nextState.totalElements||nextState.receivedElements<state.receivedElements||!equal(state.declaredCanvasIds,nextState.declaredCanvasIds)||!equal(Object.fromEntries(previousVersions),Object.fromEntries(versions))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))||[...previous.canvases].some(([id,record])=>!equal(record,next.canvases.get(id)))||[...previous.elements].some(([id,record])=>!equal(record,next.elements.get(id))))throw fault('loading_source_mismatch');
|
||||
}
|
||||
/** Full fresh reads may include new records in an unchanged-version scope. Known
|
||||
* records still cannot change without an owner version advancing. */
|
||||
@@ -72,9 +76,9 @@ export function validateLoadingCompletion(previous:Document,previousVersions:Rea
|
||||
for(const [id,record]of previous.elements)if(previousVersions.get(record.ownerCanvasId)===versions.get(record.ownerCanvasId)&&!equal(record,next.elements.get(id)))throw fault('canonical_version_mismatch',[record.ownerCanvasId]);
|
||||
for(const [id,dead]of previous.tombstones)if((next.tombstones.get(id)??0)<dead)throw fault('stale_canonical_lifecycle',[id]);
|
||||
const same=equal(Object.fromEntries(previousVersions),Object.fromEntries(versions));
|
||||
if(state.version!==1&&!(state.version===4&&fresh)&&(!fresh||same)){const counts=new Map<Id,number>();for(const element of next.elements.values())counts.set(element.ownerCanvasId,(counts.get(element.ownerCanvasId)??0)+1);if(state.declaredCanvasIds.some(id=>(counts.get(id)??0)!==state.canvasElementCounts[id]))throw fault('invalid_model_coverage');}
|
||||
if(state.version!==1&&!(state.version>=4&&fresh)&&(!fresh||same)){const counts=new Map<Id,number>();for(const element of next.elements.values())counts.set(element.ownerCanvasId,(counts.get(element.ownerCanvasId)??0)+1);if(state.declaredCanvasIds.some(id=>(counts.get(id)??0)!==state.canvasElementCounts[id]))throw fault('invalid_model_coverage');}
|
||||
if(!fresh&&(!same||sourceToken!==state.sourceToken))throw fault('loading_source_mismatch');
|
||||
if(same&&!(state.version===4&&fresh)&&(next.elements.size!==state.totalElements||next.canvases.size!==state.declaredCanvasIds.length||state.declaredCanvasIds.some(id=>!next.canvases.has(id))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))))throw fault('incomplete_loading_model');
|
||||
if(same&&!(state.version>=4&&fresh)&&(next.elements.size!==state.totalElements||next.canvases.size!==state.declaredCanvasIds.length||state.declaredCanvasIds.some(id=>!next.canvases.has(id))||!equal(Object.fromEntries(previous.tombstones),Object.fromEntries(next.tombstones))))throw fault('incomplete_loading_model');
|
||||
}
|
||||
|
||||
/** Counts refer to the read baseline, never to optimistic working membership. */
|
||||
@@ -88,7 +92,7 @@ export function loadingQueryCoverage(snapshot:import('./contracts.ts').EngineSna
|
||||
const loading=snapshot.runtime.loading,base=snapshot.runtime.loadingRead?.document??snapshot.runtime.acknowledged;
|
||||
if(!loading)return{projectComplete:true,topologyComplete:true,completeCanvasIds:[...snapshot.document.canvases.keys()]};
|
||||
const counts=directCounts(base);
|
||||
return{...(loading.version===4?{elementIds:namespace(loading.elementOwners),loadedElementIds:loadedNamespace(base)}:{}),projectComplete:false,topologyComplete:base.canvases.size===loading.declaredCanvasIds.length,completeCanvasIds:loading.version===4?[...snapshot.document.canvases.keys()].filter(id=>ownerReady(loading,snapshot.runtime.loadingRead!,snapshot.document,id)):loading.version!==1?[...base.canvases.keys()].filter(id=>snapshot.document.canvases.has(id)&&(counts.get(id)??0)===loading.canvasElementCounts[id]):[]};
|
||||
return{...((loading.version===4||loading.version===5)?{elementIds:namespace(loading.elementOwners),loadedElementIds:loadedNamespace(base)}:{}),projectComplete:false,topologyComplete:base.canvases.size===loading.declaredCanvasIds.length,completeCanvasIds:(loading.version===4||loading.version===5)?[...snapshot.document.canvases.keys()].filter(id=>ownerReady(loading,snapshot.runtime.loadingRead!,snapshot.document,id)):loading.version!==1?[...base.canvases.keys()].filter(id=>snapshot.document.canvases.has(id)&&(counts.get(id)??0)===loading.canvasElementCounts[id]):[]};
|
||||
}
|
||||
|
||||
/** Validate the retained source separately from the moving ACK baseline. Only
|
||||
@@ -96,7 +100,7 @@ export function loadingQueryCoverage(snapshot:import('./contracts.ts').EngineSna
|
||||
function validateLoadingRead(runtime:RuntimeState,registry:Registry):void {
|
||||
const source=runtime.loadingRead!,loading=runtime.loading!,base=source.document,ack=runtime.acknowledged;
|
||||
validateDocument(base,registry);
|
||||
if(loading.version===4){validateStructuralRead(runtime,registry);return;}
|
||||
if(loading.version>=4){validateStructuralRead(runtime,registry);return;}
|
||||
if(base.projectId!==ack.projectId||!equal(Object.fromEntries(base.canvases),Object.fromEntries(ack.canvases))||!equal(Object.fromEntries(base.tombstones),Object.fromEntries(ack.tombstones))||base.elements.size!==ack.elements.size||source.versions.size!==runtime.versions.size)throw fault('invalid_loading_read');
|
||||
const ready=new Set(loadingQueryCoverage({runtime,document:ack} as import('./contracts.ts').EngineSnapshot).completeCanvasIds);
|
||||
for(const [id,version]of source.versions){const current=runtime.versions.get(id);if(current===undefined||!Number.isSafeInteger(current)||current<version||current!==version&&!ready.has(id))throw fault('invalid_loading_read',[id]);}
|
||||
@@ -112,7 +116,7 @@ function validateLoadingRead(runtime:RuntimeState,registry:Registry):void {
|
||||
/** Overlay only scopes whose ACK version advanced since this immutable capture. */
|
||||
export function mergeLoadingRead(runtime:RuntimeState,read:CanonicalProposal,registry:Registry):CanonicalProposal {
|
||||
const source=runtime.loadingRead;if((!runtime.loading||runtime.loading.version<3)||!source)return read;
|
||||
if(runtime.loading.version===4){const projected=appliedSource(runtime,read);validateDocument(projected.document,registry);return projected;}
|
||||
if(runtime.loading.version>=4){const projected=appliedSource(runtime,read);validateDocument(projected.document,registry);return projected;}
|
||||
const advanced=new Set([...runtime.versions].filter(([id,version])=>version>(source.versions.get(id)??Infinity)).map(([id])=>id));
|
||||
const elements=[...read.document.elements.values()].filter(record=>!advanced.has(record.ownerCanvasId));
|
||||
for(const element of runtime.acknowledged.elements.values())if(advanced.has(element.ownerCanvasId))elements.push(element);
|
||||
@@ -121,7 +125,7 @@ export function mergeLoadingRead(runtime:RuntimeState,read:CanonicalProposal,reg
|
||||
}
|
||||
function importDocument(document:Document,elements:readonly import('../canvas-core/types.ts').ElementRecord[]):Document{return createDocument(document.projectId,[...document.canvases.values()],elements,document.tombstones);}
|
||||
export function loadingCanSend(runtime:RuntimeState,canvasIds:readonly Id[],document=runtime.acknowledged):boolean {
|
||||
if(!runtime.loading)return true;if(runtime.loading.version<3)return false;if(runtime.loading.version===4)return canvasIds.length>0&&canvasIds.every(id=>structuralRequestScope(runtime,runtime.loadingRead!,id));
|
||||
if(!runtime.loading)return true;if(runtime.loading.version<3)return false;if(runtime.loading.version>=4)return canvasIds.length>0&&canvasIds.every(id=>structuralRequestScope(runtime,runtime.loadingRead!,id));
|
||||
const ready=new Set(loadingQueryCoverage({runtime,document} as import('./contracts.ts').EngineSnapshot).completeCanvasIds);return canvasIds.length>0&&canvasIds.every(id=>ready.has(id));
|
||||
}
|
||||
|
||||
@@ -132,7 +136,7 @@ function validateStructuralRead(runtime:RuntimeState,registry:Registry){
|
||||
for(const request of runtime.ledger.requests.values())if(request.protocolVersion!==2||!request.canvasIds.length||Object.keys(request.expectedVersions).length!==request.canvasIds.length||request.canvasIds.some(id=>!structuralRequestScope(runtime,source,id)||!Number.isSafeInteger(request.expectedVersions[id])||request.expectedVersions[id]<(source.versions.get(id)??0)||request.expectedVersions[id]>(runtime.versions.get(id)??0)))throw fault('invalid_loading_request');
|
||||
}
|
||||
|
||||
function structuralRequestScope(runtime:RuntimeState,source:CanonicalProposal,id:Id):boolean {
|
||||
export function structuralRequestScope(runtime:RuntimeState,source:CanonicalProposal,id:Id):boolean {
|
||||
if(source.versions.has(id))return ownerReady(runtime.loading!,source,source.document,id);
|
||||
return [...runtime.ledger.operations.values()].some(record=>record.intent.changes.some(change=>change.kind==='canvas'&&change.id===id&&!change.before&&!!change.after));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {validateElementOwners,type ElementOwners,validateCanvasElementCounts,type CanvasElementCounts} from './scope-counts.ts';
|
||||
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';
|
||||
@@ -6,7 +6,7 @@ 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 document:Document;readonly receivedElements:number;readonly totalElements:number;readonly waitingElements:number;readonly complete:false };
|
||||
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){
|
||||
@@ -34,7 +34,7 @@ export function createModelPreview(registry:Registry){
|
||||
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));}header=true;
|
||||
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.
|
||||
@@ -59,7 +59,7 @@ export function createModelPreview(registry:Registry){
|
||||
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}:{}),...(canvasElementCounts?{canvasElementCounts}:{}),document:published,receivedElements:received,totalElements:expected,waitingElements:received-published.elements.size,complete:false});return snapshot;
|
||||
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;}});
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ export function encodeRecoveryValue(input:unknown):Json {
|
||||
}
|
||||
|
||||
/** Only codec-owned, validated immutable images qualify for the storage copy fast path. */
|
||||
export function sealRecoveryImage(formatVersion:1|2|3|4|5|6|7|8|9|10,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
|
||||
if(![1,2,3,4,5,6,7,8,9,10].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image');
|
||||
export function sealRecoveryImage(formatVersion:1|2|3|4|5|6|7|8|9|10|11,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
|
||||
if(![1,2,3,4,5,6,7,8,9,10,11].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image');
|
||||
const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image;
|
||||
}
|
||||
export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage {
|
||||
operations: [...snapshot.operations],
|
||||
runtime: { ...(snapshot.runtime.loadingRead?{loadingRead:{document:documentValue(snapshot.runtime.loadingRead.document),versions:[...snapshot.runtime.loadingRead.versions]}}:{}), ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:[...snapshot.runtime.orphanFiles]}:{}), imports:[...(snapshot.runtime.imports??[])], recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } },
|
||||
};
|
||||
return sealRecoveryImage(snapshot.runtime.loading?(snapshot.runtime.loading.version===4?10:snapshot.runtime.loading.version===3?9:snapshot.runtime.loading.version===2?8:7):[...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload);
|
||||
return sealRecoveryImage(snapshot.runtime.loading?(snapshot.runtime.loading.version===5?11:snapshot.runtime.loading.version===4?10:snapshot.runtime.loading.version===3?9:snapshot.runtime.loading.version===2?8:7):[...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload);
|
||||
}
|
||||
type ObjectValue = Record<string, unknown>;
|
||||
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
|
||||
@@ -92,12 +92,12 @@ function readHistory(history:ObjectValue,operations:ReadonlyMap<Id,OperationInte
|
||||
return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries});
|
||||
}
|
||||
export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSnapshot & { readonly rootRevision: number; readonly runtime: RuntimeState } {
|
||||
if (![1,2,3,4,5,6,7,8,9,10].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (![1,2,3,4,5,6,7,8,9,10,11].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
integer(image.rootRevision); integer(image.localRevision);
|
||||
if (image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
|
||||
// Own the parsed records; mutating a caller's RecoveryImage must not mutate live state.
|
||||
const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger);
|
||||
if(runtime.loading!==undefined&&image.formatVersion!==(object(runtime.loading).version===4?10:object(runtime.loading).version===3?9:object(runtime.loading).version===2?8:7))throw fault('unsupported_recovery_version');
|
||||
if(runtime.loading!==undefined&&image.formatVersion!==(object(runtime.loading).version===5?11:object(runtime.loading).version===4?10:object(runtime.loading).version===3?9:object(runtime.loading).version===2?8:7))throw fault('unsupported_recovery_version');
|
||||
if(image.formatVersion>=7&&runtime.loading===undefined)throw fault('invalid_loading_state');
|
||||
const operations = table(raw.operations, intent),restoredHistory=readHistory(history,operations);
|
||||
if(image.formatVersion<6&&[...operations.values()].some(intent=>intent.historyEffect))throw fault('unsupported_recovery_version');
|
||||
|
||||
@@ -19,3 +19,15 @@ export function validateElementOwners(value:unknown,counts:CanvasElementCounts,t
|
||||
for(const [id,owner]of Object.entries(value)){if(!id||typeof owner!=='string'||!Object.hasOwn(counts,owner))throw fault('invalid_model_identity');actual[owner]=(actual[owner]??0)+1;}
|
||||
if(Object.keys(counts).some(id=>(actual[id]??0)!==counts[id]))throw fault('invalid_model_identity');
|
||||
}
|
||||
|
||||
/** Full captured topology, independent of which entry payloads have arrived. */
|
||||
export function validateCanvasTopology(canvases:readonly import('../canvas-core/types.ts').CanvasRecord[],owners:ElementOwners,projectId:Id):void {
|
||||
if(!Array.isArray(canvases)||!canvases.length)throw fault('invalid_model_topology');
|
||||
const table=new Map(canvases.map(canvas=>[canvas?.id,canvas]));if(table.size!==canvases.length||!table.has(projectId))throw fault('invalid_model_topology');
|
||||
let roots=0;const entries=new Set<Id>();
|
||||
for(const canvas of canvases){if(!canvas||typeof canvas.id!=='string'||!canvas.id||!Object.hasOwn(canvas,'parent'))throw fault('invalid_model_topology');if(canvas.parent===null){roots++;if(canvas.id!==projectId)throw fault('invalid_model_topology');continue;}
|
||||
const parent=canvas.parent;if(!parent||!table.has(parent.canvasId)||typeof parent.entryElementId!=='string'||!parent.entryElementId||entries.has(parent.entryElementId)||owners[parent.entryElementId]!==parent.canvasId)throw fault('invalid_model_topology');entries.add(parent.entryElementId);
|
||||
const seen=new Set<Id>();let current=canvas;while(current.parent){if(seen.has(current.id))throw fault('invalid_model_topology');seen.add(current.id);const next=table.get(current.parent.canvasId);if(!next)throw fault('invalid_model_topology');current=next;}
|
||||
}
|
||||
if(roots!==1)throw fault('invalid_model_topology');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Json } from '../canvas-core/types.ts';
|
||||
|
||||
/** Portable durable-store contract. Browser database types belong to the adapter. */
|
||||
export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string };
|
||||
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
|
||||
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
|
||||
export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] };
|
||||
export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise<void> };
|
||||
export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string};
|
||||
|
||||
@@ -30,7 +30,7 @@ function purgedFileIds(image:RecoveryImage):Set<string>{
|
||||
return result;
|
||||
}
|
||||
function validateImage(image: RecoveryImage) {
|
||||
if (![1,2,3,4,5,6,7,8,9,10].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (![1,2,3,4,5,6,7,8,9,10,11].includes(image.formatVersion)) throw fault('unsupported_recovery_version');
|
||||
if (!Number.isSafeInteger(image.rootRevision) || image.rootRevision < 0 || !Number.isSafeInteger(image.localRevision) || image.localRevision < 0 || image.localRevision > image.rootRevision || !validJson(image.payload)) throw fault('corrupt_recovery_image');
|
||||
}
|
||||
const requestValue = <T>(request: IDBRequest<T>) => new Promise<T>((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
|
||||
|
||||
Reference in New Issue
Block a user