feat(canvas): add bounded resumable import coordination

This commit is contained in:
2026-11-08 15:40:10 +08:00
parent be94446f12
commit 160c16afb7
4 changed files with 314 additions and 3 deletions
+51
View File
@@ -0,0 +1,51 @@
import type { Document, ElementRecord, Id, Json, Registry } from '../canvas-core/types.ts';
import { remapCopyData } from '../canvas-core/duplicate.ts';
import { clone, fault } from '../canvas-core/values.ts';
import type { ImportJob, ImportLine, ImportUnit } from './imports.ts';
export function assertNativeImportTarget(job:ImportJob,document:Document){
const source=job.source,canvas=source?document.canvases.get(source.targetCanvasId):undefined;
if(!source||!canvas)throw fault('import_target_missing');
if(source.targetEntry?(canvas.parent?.entryElementId!==source.targetEntry.id||document.elements.get(source.targetEntry.id)?.incarnation!==source.targetEntry.incarnation):canvas.parent!==null)throw fault('import_target_changed');
return source;
}
export function assertNativeImportDependencies(job:ImportJob,document:Document,dependencies:readonly string[]){
for(const key of dependencies)if(job.committedSourceKeys.includes(key)){
const id=job.stableIds[key],binding=job.bindings?.[id];
if(!binding)throw fault('import_dependency_changed');
if(binding.kind==='element'){if(document.elements.get(id)?.incarnation!==binding.incarnation)throw fault('import_dependency_changed',[id]);}
else{const canvas=document.canvases.get(id),entry=binding.entryElementId,entryBinding=entry?job.bindings?.[entry]:undefined;if(!canvas||canvas.parent?.entryElementId!==entry||entry&&(entryBinding?.kind!=='element'||document.elements.get(entry)?.incarnation!==entryBinding.incarnation))throw fault('import_dependency_changed',[id]);}
}
}
/** Native NDJSON records use source IDs; $target names the explicitly chosen existing canvas. */
export function prepareNativeImport(line:ImportLine,job:ImportJob,reserve:(key:string)=>Id,document:Document,registry:Registry):ImportUnit {
const source=assertNativeImportTarget(job,document);
const raw=line.value as Record<string,Json>|undefined;
if(!raw||Array.isArray(raw)||!['element','canvas'].includes(raw.kind as string)||typeof raw.key!=='string'||!raw.key||raw.key==='$target'||!raw.record||typeof raw.record!=='object'||Array.isArray(raw.record))throw fault('invalid_native_import_record');
const record=clone(raw.record) as Record<string,Json>;if(record.id!==raw.key)throw fault('import_source_identity_mismatch');
const dependencies=new Set<string>(),elementIds=new Map<Id,Id>(),canvasIds=new Map<Id,Id>();
const element=(id:Json):Id=>{if(typeof id!=='string'||!id)throw fault('invalid_import_reference');const key=`element:${id}`;dependencies.add(key);const mapped=reserve(key);elementIds.set(id,mapped);return mapped;};
const owner=(id:Json):Id=>{if(id==='$target'){canvasIds.set('$target',source.targetCanvasId);return source.targetCanvasId;}if(typeof id!=='string'||!id)throw fault('invalid_import_reference');const key=`canvas:${id}`;dependencies.add(key);const mapped=reserve(key);canvasIds.set(id,mapped);return mapped;};
const key=`${raw.kind}:${raw.key}`,id=reserve(key);
if(raw.kind==='canvas'){
const parent=record.parent as Record<string,Json>|null;
if(!parent||typeof parent!=='object'||Array.isArray(parent))throw fault('invalid_import_canvas');
const after={...record,id,parent:{...parent,canvasId:owner(parent.canvasId),entryElementId:element(parent.entryElementId)}};
dependencies.delete(key);return{key,dependencies:[...dependencies],changes:[{kind:'canvas',id,before:null,after:after as never}]};
}
const original=record as unknown as ElementRecord,definition=registry.components.get(original.componentId);
if(registry.resolve(original)!=='known'||!definition?.copy||!original.data||typeof original.data!=='object'||Array.isArray(original.data))throw fault('import_component_unavailable');
for(const ref of definition.copy.references){
let value:Json|undefined=original.data;for(const part of ref.path){if(!value||typeof value!=='object'||!Object.hasOwn(value,part)){value=undefined;break;}value=(value as Record<string,Json>)[part];}
if(value===undefined||value===null)continue;
if(ref.many&&!Array.isArray(value))throw fault('invalid_import_reference');
for(const item of ref.many?value as Json[]:[value]){if(typeof item!=='string'||!item)throw fault('invalid_import_reference');if(ref.target==='element')element(item);else owner(item);}
}
const after={...record,id,incarnation:1,ownerCanvasId:owner(original.ownerCanvasId),data:remapCopyData(original,registry,elementIds,canvasIds,document)};
if(original.structure==='connection'){
if(!original.source||!original.target)throw fault('invalid_import_endpoint');
Object.assign(after,{source:{...original.source,elementId:element(original.source.elementId)},target:{...original.target,elementId:element(original.target.elementId)}});
}else if(original.structure!=='node')throw fault('invalid_import_structure');
dependencies.delete(key);return{key,dependencies:[...dependencies],changes:[{kind:'element',id,before:null,after:after as ElementRecord}]};
}