Resume durable copy preparation from saved planning cursors

This commit is contained in:
2026-11-08 20:03:02 +08:00
parent a51d189b8e
commit ed3c3a2ab3
10 changed files with 195 additions and 36 deletions
@@ -0,0 +1,72 @@
import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts';
import { ancestors, targetElement, validateDocumentSteps } from './document.ts';
import { duplicateTreeChange, duplicateEdgeChange, duplicateOverlay, type DuplicateOptions, type DuplicatePlan } from './duplicate.ts';
import { clone, equal, fault, freeze, validJson } from './values.ts';
const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done'] as const;
type Task={kind:'element'|'canvas';id:Id};
/** Internal preparation state: IDs/cursors only. Emitted records are a deterministic derived cache. */
type MutableCheckpoint={version:1;projectId:Id;sourceToken:string;schemaToken:string;selection:Target[];offset:Point;mapping?:DuplicateOptions['mapping'];phase:typeof phases[number];cursor:number;steps:number;selected:Id[];children:[Id,Id][];members:[Id,Id[]][];roots:Id[];copied:Id[];canvases:Id[];order:Task[];work:Task[];expanding?:{canvas:Id;index:number};edges:Id[];elementIds:[Id,Id][];canvasIds:[Id,Id][];emitted:number;emittedEdges:number};
type Immutable<T>=T extends object?{readonly [K in keyof T]:Immutable<T[K]>}:T;
export type DuplicateCheckpoint=Immutable<MutableCheckpoint>;
const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id);
export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{
if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable');
if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint');
for(const list of [value.selected,value.roots,value.copied,value.canvases,value.edges])if(!ids(list)||new Set(list).size!==list.length)throw fault('invalid_copy_checkpoint');
for(const list of [value.order,value.work])if(!Array.isArray(list)||list.some(t=>!t||!['element','canvas'].includes(t.kind)||typeof t.id!=='string'||!t.id))throw fault('invalid_copy_checkpoint');
for(const list of [value.children,value.elementIds,value.canvasIds])if(!Array.isArray(list)||list.some(pair=>!ids(pair)||pair.length!==2)||new Set(list.map(pair=>pair[0])).size!==list.length)throw fault('invalid_copy_checkpoint');
if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint');
}
export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint}){
const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined;
const state:MutableCheckpoint=binding.checkpoint?clone(binding.checkpoint) as MutableCheckpoint:{version:1,projectId:document.projectId,sourceToken:binding.sourceToken,schemaToken:binding.schemaToken,selection:clone(selection) as Target[],offset:clone(offset),...(suppliedMapping?{mapping:suppliedMapping}:{}),phase:'selection',cursor:0,steps:0,selected:[],children:[],members:[],roots:[],copied:[],canvases:[],order:[],work:[],edges:[],elementIds:[],canvasIds:[],emitted:0,emittedEdges:0};
validateDuplicateCheckpoint(state);
if(state.projectId!==document.projectId||state.sourceToken!==binding.sourceToken||state.schemaToken!==binding.schemaToken||!equal(state.selection,selection)||!equal(state.offset,offset)||!equal(state.mapping,suppliedMapping))throw fault('copy_checkpoint_binding_mismatch');
const sourceElements=[...document.elements.values()],sourceCanvases=[...document.canvases.values()],selected=new Set(state.selected),copied=new Set(state.copied),canvases=new Set(state.canvases),children=new Map(state.children),members=new Map(state.members),elementIds=new Map(state.elementIds),canvasIds=new Map(state.canvasIds),used=new Set([...document.elements.keys(),...document.canvases.keys(),...document.tombstones.keys()]);
for(const id of [...state.selected,...state.roots,...state.copied,...state.edges])if(!document.elements.has(id))throw fault('invalid_copy_checkpoint');
for(const id of state.canvases)if(!document.canvases.has(id))throw fault('invalid_copy_checkpoint');
for(const [entry,id]of children)if(document.canvases.get(id)?.parent?.entryElementId!==entry)throw fault('invalid_copy_checkpoint');
for(const [owner,list]of members)for(const id of list)if(document.elements.get(id)?.ownerCanvasId!==owner)throw fault('invalid_copy_checkpoint');
for(const [list,scope]of [[state.elementIds,copied],[state.canvasIds,canvases]] as const)for(const [id,mapped]of list){if(!scope.has(id)||used.has(mapped))throw fault('invalid_copy_checkpoint');used.add(mapped);}
for(const task of [...state.order,...state.work])if(!(task.kind==='element'?document.elements:document.canvases).has(task.id))throw fault('invalid_copy_checkpoint');
if(phases.indexOf(state.phase)>=phases.indexOf('emit')&&(elementIds.size!==copied.size||canvasIds.size!==canvases.size))throw fault('invalid_copy_checkpoint');
const limits:Partial<Record<DuplicateCheckpoint['phase'],number>>={selection:state.selection.length,canvases:sourceCanvases.length,members:sourceElements.length,roots:state.selected.length,seed:state.roots.length,edges:sourceElements.length,'allocate-elements':state.copied.length,'allocate-canvases':state.canvases.length};if(limits[state.phase]!==undefined&&state.cursor>limits[state.phase]!)throw fault('invalid_copy_checkpoint');
const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[];
const outputElements=new Map<Id,ElementRecord>(),outputCanvases=new Map<Id,CanvasRecord>(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[];
let treeCache=0,edgeCache=0,sourceChecks:Generator<void>|undefined=validateDocumentSteps(document,registry),outputChecks:Generator<void>|undefined,validated=false;
const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;};
function add(change:Exclude<Change,{kind:'field'}>){freeze(change);changes.push(change);if(change.kind==='element')outputElements.set(change.id,change.after!);else outputCanvases.set(change.id,change.after!);}
function tree(index:number){const task=state.order[index];add(duplicateTreeChange(document,registry,task.kind,(task.kind==='element'?document.elements:document.canvases).get(task.id)!,elementIds,canvasIds,canvases,state.offset));}
function edge(index:number){add(duplicateEdgeChange(document,registry,document.elements.get(state.edges[index])!,elementIds,canvasIds,output));}
function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;}
return Object.freeze({
checkpoint():DuplicateCheckpoint{return freeze(clone(state));},
step():boolean{
if(sourceChecks){if(sourceChecks.next().done)sourceChecks=undefined;return false;}
// Reconstruct only the derived output cache. Index/DFS/allocation work stays at its saved cursor.
if(treeCache<state.emitted){tree(treeCache++);return false;}if(edgeCache<state.emittedEdges){edge(edgeCache++);return false;}
if(state.phase==='validate'||state.phase==='done'){outputChecks??=validateDocumentSteps(output,registry);if(!outputChecks.next().done)return false;validated=true;if(state.phase!=='done'){phase('done');state.steps++;}return true;}
switch(state.phase){
case 'selection':if(state.cursor<state.selection.length){const id=targetElement(document,state.selection[state.cursor++]).id;if(!selected.has(id)){selected.add(id);state.selected.push(id);}}else{if(!selected.size)throw fault('empty_copy_selection');phase('canvases');}break;
case 'canvases':if(state.cursor<sourceCanvases.length){const canvas=sourceCanvases[state.cursor++];if(canvas.parent){children.set(canvas.parent.entryElementId,canvas.id);state.children.push([canvas.parent.entryElementId,canvas.id]);}}else phase('members');break;
case 'members':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];let list=members.get(record.ownerCanvasId);if(!list){list=[];members.set(record.ownerCanvasId,list);state.members.push([record.ownerCanvasId,list]);}list.push(record.id);}else phase('roots');break;
case 'roots':if(state.cursor<state.selected.length){const id=state.selected[state.cursor++];if(!ancestors(document,document.elements.get(id)!.ownerCanvasId).some(canvas=>{const parent=document.canvases.get(canvas)!.parent;return parent&&selected.has(parent.entryElementId);}))state.roots.push(id);}else phase('seed',state.roots.length-1);break;
case 'seed':if(state.cursor>=0){const id=state.roots[state.cursor--];if(document.elements.get(id)!.structure==='node')state.work.push({kind:'element',id});}else phase('dfs');break;
case 'dfs':{
if(state.expanding){const list=members.get(state.expanding.canvas)??[];if(state.expanding.index>=list.length)throw fault('invalid_copy_checkpoint');if(state.expanding.index<0)delete state.expanding;else{const id=list[state.expanding.index--];if(document.elements.get(id)!.structure==='node')state.work.push({kind:'element',id});}break;}
const task=state.work.pop();if(!task){phase('edges');break;}
if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};}
else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break;
}
case 'edges':if(state.cursor<sourceElements.length){const record=sourceElements[state.cursor++];if(record.structure==='connection'&&(selected.has(record.id)||canvases.has(record.ownerCanvasId)||copied.has(record.target.elementId))){state.edges.push(record.id);copied.add(record.id);state.copied.push(record.id);}}else phase('allocate-elements');break;
case 'allocate-elements':if(state.cursor<state.copied.length){const id=state.copied[state.cursor++],value=allocate(id,'elements');elementIds.set(id,value);state.elementIds.push([id,value]);}else phase('allocate-canvases');break;
case 'allocate-canvases':if(state.cursor<state.canvases.length){const id=state.canvases[state.cursor++],value=allocate(id,'canvases');canvasIds.set(id,value);state.canvasIds.push([id,value]);}else phase('mapping');break;
case 'mapping':if(state.cursor<supplied.length){const value=supplied[state.cursor++];if(!(value.kind==='element'?copied:canvases).has(value.id))throw fault('copy_mapping_scope_mismatch');}else phase('emit');break;
case 'emit':if(state.emitted<state.order.length){tree(state.emitted++);treeCache++;}else phase('emit-edges');break;
case 'emit-edges':if(state.emittedEdges<state.edges.length){edge(state.emittedEdges++);edgeCache++;}else phase('validate');break;
}
state.steps++;return false;
},
result():DuplicatePlan{if(!validated||state.phase!=='done')throw fault('copy_plan_not_ready');return freeze({version:1,roots:state.roots.map(id=>elementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});},
});
}
+34 -25
View File
@@ -77,27 +77,44 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
const changes: Change[] = [];
for (const item of order) {
yield;
if (item.kind === 'canvas') {
const parent = item.record.parent!;
const after = { ...clone(item.record), id: canvasIds.get(item.record.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
changes.push({ kind: 'canvas', id: after.id, before: null, after });
} else {
const old = item.record; if (old.structure !== 'node') throw fault('invalid_copy_node');
let geometry = clone(old.geometry);
if (!copiedCanvases.has(old.ownerCanvasId)) {
const transform = canvasTransform(document, old.ownerCanvasId), zero = toLocal(transform, { x: 0, y: 0 }), shift = toLocal(transform, offset);
geometry = { ...geometry, x: geometry.x + shift.x - zero.x, y: geometry.y + shift.y - zero.y };
}
const after = { ...clone(old), id: elementIds.get(old.id)!, incarnation: 1, ownerCanvasId: canvasIds.get(old.ownerCanvasId) ?? old.ownerCanvasId, data: remapCopyData(old, registry, elementIds, canvasIds, document), geometry };
changes.push({ kind: 'element', id: after.id, before: null, after });
}
changes.push(duplicateTreeChange(document,registry,item.kind,item.record,elementIds,canvasIds,copiedCanvases,offset));
}
const nodeElements = new Map<Id, ElementRecord>(), nodeCanvases = new Map<Id, CanvasRecord>();
for (const change of changes) { yield; if (change.kind === 'element' && change.after) nodeElements.set(change.id, change.after); else if (change.kind === 'canvas' && change.after) nodeCanvases.set(change.id, change.after); }
const nodes: Document = { ...document, elements: overlay(document.elements, nodeElements), canvases: overlay(document.canvases, nodeCanvases) };
for (const edge of edges) {
yield;
if (edge.structure !== 'connection') continue;
const change=duplicateEdgeChange(document,registry,edge,elementIds,canvasIds,nodes);
changes.push(change);nodeElements.set(change.id,change.after!);
}
yield* validateDocumentSteps(nodes, registry);
const elementMapping: Record<Id,Id> = {}, canvasMapping: Record<Id,Id> = {}, copiedRoots: Id[] = [];
for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); }
for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); }
for (const id of roots) { yield; copiedRoots.push(elementIds.get(id)!); }
for (const change of changes) { yield; freeze(change); }
return Object.freeze({ version: 1, roots: Object.freeze(copiedRoots), elementIds: Object.freeze(elementMapping), canvasIds: Object.freeze(canvasMapping), changes: Object.freeze(changes) });
}
/** Shared deterministic emitters for synchronous and checkpointed planners. */
export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,copiedCanvases:ReadonlySet<Id>,offset:Point):Exclude<Change,{kind:'field'}>{
if (kind === 'canvas') {
const canvas=record as CanvasRecord,parent=canvas.parent!;
const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
return { kind: 'canvas', id: after.id, before: null, after };
} else {
const old = record as ElementRecord; if (old.structure !== 'node') throw fault('invalid_copy_node');
let geometry = clone(old.geometry);
if (!copiedCanvases.has(old.ownerCanvasId)) {
const transform = canvasTransform(document, old.ownerCanvasId), zero = toLocal(transform, { x: 0, y: 0 }), shift = toLocal(transform, offset);
geometry = { ...geometry, x: geometry.x + shift.x - zero.x, y: geometry.y + shift.y - zero.y };
}
const after = { ...clone(old), id: elementIds.get(old.id)!, incarnation: 1, ownerCanvasId: canvasIds.get(old.ownerCanvasId) ?? old.ownerCanvasId, data: remapCopyData(old, registry, elementIds, canvasIds, document), geometry };
return { kind: 'element', id: after.id, before: null, after };
}
}
export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{
if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint');
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]);
const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId };
const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner);
@@ -113,17 +130,9 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele
path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record<string, Json>, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) };
}
const after: ElementRecord = { ...clone(edge), id: elementIds.get(edge.id)!, incarnation: 1, ownerCanvasId: owner, source, target, path, data: remapCopyData(edge, registry, elementIds, canvasIds, document) };
changes.push({ kind: 'element', id: after.id, before: null, after });
nodeElements.set(after.id, after);
}
yield* validateDocumentSteps(nodes, registry);
const elementMapping: Record<Id,Id> = {}, canvasMapping: Record<Id,Id> = {}, copiedRoots: Id[] = [];
for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); }
for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); }
for (const id of roots) { yield; copiedRoots.push(elementIds.get(id)!); }
for (const change of changes) { yield; freeze(change); }
return Object.freeze({ version: 1, roots: Object.freeze(copiedRoots), elementIds: Object.freeze(elementMapping), canvasIds: Object.freeze(canvasMapping), changes: Object.freeze(changes) });
return { kind: 'element', id: after.id, before: null, after };
}
export function duplicateOverlay(document:Document,elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>):Document{return {...document,elements:overlay(document.elements,elements),canvases:overlay(document.canvases,canvases)};}
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
+4 -1
View File
@@ -9,4 +9,7 @@ export * from './coordinates.ts';
export * from './structure.ts';
export * from './units.ts';
export * from './history-transition.ts';
export * from './duplicate.ts';
export {planDuplicate,planDuplicateSteps,remapCopyData} from './duplicate.ts';
export type {DuplicatePlan,DuplicateOptions} from './duplicate.ts';
export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts';
export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts';
+14
View File
@@ -1,3 +1,4 @@
import { createDuplicatePlanner, type DuplicateCheckpoint } from '../canvas-core/duplicate-checkpoint.ts';
import type { Document, Registry, Target } from '../canvas-core/types.ts';
import { planDuplicateSteps, type DuplicateOptions, type DuplicatePlan } from '../canvas-core/duplicate.ts';
import { clone, fault, freeze } from '../canvas-core/values.ts';
@@ -45,3 +46,16 @@ export async function planDuplicateAsync(document: Document, registry: Registry,
steps.return(undefined as never);
}
}
/** Resume semantic planning cursors. Source/output validation and derived output caches are rebuilt. */
export async function planDuplicateResumable(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint},scheduling:Omit<DuplicateScheduling,'checkpoint'>&{checkpoint?:(state:DuplicateCheckpoint)=>Promise<void>}={}):Promise<DuplicatePlan>{
const budget=scheduling.frameBudgetMs??6,limit=scheduling.maxStepsPerSlice??512,now=scheduling.now??(()=>performance.now()),yieldTask=scheduling.yieldTask??(()=>new Promise<void>(resolve=>setTimeout(resolve,0)));
if(!Number.isFinite(budget)||budget<=0||!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_copy_budget');
const check=()=>{if(scheduling.signal?.aborted)throw fault('copy_aborted');};check();
const settings=Object.freeze({createId:options.createId,...(options.worldOffset?{worldOffset:freeze(clone(options.worldOffset))}:{}),...(options.mapping?{mapping:freeze(clone(options.mapping))}:{})}),planner=createDuplicatePlanner(document,registry,freeze(clone(selection)),settings,binding);
while(true){
const start=now();let count=0;
do{check();const done=planner.step();check();if(done)return planner.result();count++;}while(count<limit&&now()-start<budget);
if(scheduling.checkpoint)await scheduling.checkpoint(planner.checkpoint());check();await yieldTask();
}
}
+7 -4
View File
@@ -1,5 +1,5 @@
import { encodeCopySnapshot, decodeCopySnapshot, copyRegistrySignature } from './copy-snapshot.ts';
import { planDuplicateAsync } from './duplicate.ts';
import { planDuplicateAsync, planDuplicateResumable } from './duplicate.ts';
import { importSourceDigest } from './import-integrity.ts';
import { encodeDuplicateSource, prepareDuplicateImport, assertDuplicateExternal } from './import-duplicate.ts';
import { createImportJob, createImportRunner, createNdjsonImportSource, type ImportJob } from './imports.ts';
@@ -331,12 +331,15 @@ export async function openProjectEngine(options: EngineOptions) {
await persist();check();const document=await decodeCopySnapshot(bytes,options.scope.projectId,options.registry,controller.signal);
if(copyRegistrySignature(document,options.registry)!==preparation.registrySignature)throw fault('copy_interpreter_changed');
const allocationIds=[...preparation.allocationIds];let cursor=0;
const plan=await planDuplicateAsync(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId:()=>{const index=cursor++;if(index<allocationIds.length)return allocationIds[index];const id=createId();allocationIds.push(id);return id;}},{signal:controller.signal,checkpoint:async steps=>{
const plan=preparation.version===2?await planDuplicateResumable(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId},{sourceToken:admitted.source.sha256!,schemaToken:preparation.registrySignature,checkpoint:preparation.planner},{signal:controller.signal,checkpoint:async state=>{
const job=check(),previous=job.preparation!;if(previous.planner&&state.steps<=previous.steps)return;
publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:state.steps,allocationIds:[...state.elementIds,...state.canvasIds].map(pair=>pair[1]),planner:state}}),job.generation);await persist();check();
}}):await planDuplicateAsync(document,options.registry,preparation.selection,{worldOffset:preparation.worldOffset,createId:()=>{const index=cursor++;if(index<allocationIds.length)return allocationIds[index];const id=createId();allocationIds.push(id);return id;}},{signal:controller.signal,checkpoint:async steps=>{
const job=check(),previous=job.preparation!;
if(steps<=previous.steps&&allocationIds.length===previous.allocationIds.length)return;
publishImport(freeze({...job,generation:job.generation+1,preparation:{...previous,steps:Math.max(previous.steps,steps),allocationIds:[...allocationIds]}}),job.generation);await persist();check();
}});
check();if(cursor!==allocationIds.length)throw fault('copy_interpreter_changed');
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 importSourceDigest(encoded,controller.signal),job=check();assertWritable();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');
@@ -402,7 +405,7 @@ export async function openProjectEngine(options: EngineOptions) {
const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;
if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||[...(current.imports?.values()??[])].some(job=>job.source?.fileId===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',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:1 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:{kind:'duplicate-snapshot',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:2 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;
})();
+5 -2
View File
@@ -1,3 +1,4 @@
import { validateDuplicateCheckpoint, type DuplicateCheckpoint } from '../canvas-core/duplicate-checkpoint.ts';
import type { Change, Id, Json, Result, Target, Point } from '../canvas-core/types.ts';
import { selectImportGroups } from './import-groups.ts';
import { clone, equal, fault, freeze, validJson } from '../canvas-core/values.ts';
@@ -12,7 +13,7 @@ export type ImportBinding = {readonly kind:'element';readonly incarnation:number
export type ImportJob = {
readonly version: 1 | 2 | 3; readonly id: Id; readonly projectId: Id; readonly sourceFingerprint: string; readonly parserVersion: number;
readonly source?: NativeImportSource;
readonly preparation?: {readonly version:1;readonly registrySignature:string;readonly selection:readonly Target[];readonly worldOffset:Point;readonly allocationIds:readonly Id[];readonly steps:number};
readonly preparation?: {readonly version:1|2;readonly planner?:DuplicateCheckpoint;readonly registrySignature:string;readonly selection:readonly Target[];readonly worldOffset:Point;readonly allocationIds:readonly Id[];readonly steps:number};
readonly generation: number; readonly status: 'preparing' | 'paused' | 'running' | 'completed' | 'partial' | 'cancelled'; readonly cursor: number; readonly eof: boolean;
readonly bindings?: Readonly<Record<Id,ImportBinding>>;
readonly stableIds: Readonly<Record<string, Id>>; readonly pending: readonly ImportLocator[]; readonly committedSourceKeys: readonly string[]; readonly failures: readonly ImportFailure[]; readonly error?: string;
@@ -34,7 +35,9 @@ export function validateImportJob(job: ImportJob): void {
}
if(job.source?.kind==='duplicate-snapshot'){
const p=job.preparation;
if(!p||p.version!==1||typeof p.registrySignature!=='string'||!p.registrySignature||!Array.isArray(p.selection)||!p.selection.length||p.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!p.worldOffset||![p.worldOffset.x,p.worldOffset.y].every(Number.isFinite)||!Array.isArray(p.allocationIds)||p.allocationIds.some(id=>typeof id!=='string'||!id)||new Set(p.allocationIds).size!==p.allocationIds.length||!Number.isSafeInteger(p.steps)||p.steps<0||!['preparing','paused','cancelled'].includes(job.status)||job.cursor!==0||job.eof||job.pending.length||job.committedSourceKeys.length||Object.keys(job.stableIds).length||!job.source.copy||!Number.isSafeInteger(job.source.copy.sourceRevision)||job.source.copy.sourceRevision<0)throw fault('invalid_copy_preparation');
if(!p||![1,2].includes(p.version)||typeof p.registrySignature!=='string'||!p.registrySignature||!Array.isArray(p.selection)||!p.selection.length||p.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!p.worldOffset||![p.worldOffset.x,p.worldOffset.y].every(Number.isFinite)||!Array.isArray(p.allocationIds)||p.allocationIds.some(id=>typeof id!=='string'||!id)||new Set(p.allocationIds).size!==p.allocationIds.length||!Number.isSafeInteger(p.steps)||p.steps<0||!['preparing','paused','cancelled'].includes(job.status)||job.cursor!==0||job.eof||job.pending.length||job.committedSourceKeys.length||Object.keys(job.stableIds).length||!job.source.copy||!Number.isSafeInteger(job.source.copy.sourceRevision)||job.source.copy.sourceRevision<0)throw fault('invalid_copy_preparation');
if(p.planner){validateDuplicateCheckpoint(p.planner);if(p.version!==2||p.planner.sourceToken!==job.source.sha256||p.planner.schemaToken!==p.registrySignature||p.planner.projectId!==job.projectId||!equal(p.planner.selection,p.selection)||!equal(p.planner.offset,p.worldOffset)||p.planner.steps!==p.steps||!equal([...p.planner.elementIds,...p.planner.canvasIds].map(pair=>pair[1]),p.allocationIds))throw fault('invalid_copy_preparation');}
else if(p.version===2&&(p.steps!==0||p.allocationIds.length))throw fault('invalid_copy_preparation');
}else if(job.status==='preparing'||job.preparation!==undefined)throw fault('invalid_copy_preparation');
const keys=new Set<string>(),ids=Object.values(job.stableIds),stableIdSet=new Set(ids);
if (stableIdSet.size!==ids.length || ids.some(id=>typeof id!=='string'||!id) || Object.keys(job.stableIds).some(key=>!key)) throw fault('invalid_import_job');