Resume durable copy preparation from saved planning cursors
This commit is contained in:
@@ -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]});},
|
||||
});
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -394,6 +394,22 @@ async function duplicateRecoveryCase(name,assert){
|
||||
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
|
||||
}
|
||||
|
||||
async function duplicatePlannerRecoveryCase(name,assert){
|
||||
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-duplicate-planner`,storage=createBrowserStorage({name:database});
|
||||
const node=(id,owner,componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}});
|
||||
const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[node('F',root,'frame'),...Array.from({length:500},(_,i)=>node(`member-${i}`,child))]);
|
||||
let document=original,versions=new Map([[root,1],[child,1]]),engine;
|
||||
const transport=async request=>{const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'planner recovery lost expected version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);const participants=Object.keys(body.expected_versions);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}}};};
|
||||
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
|
||||
try{
|
||||
engine=await openProjectEngine(options);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);
|
||||
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>{release();reject(new Error('no durable DFS checkpoint'));},30000);const release=engine.editor.subscribe((_,snapshot)=>{const planner=snapshot.runtime.imports.get(id)?.preparation?.planner;if(planner?.phase==='dfs'&&planner.work.length){clearTimeout(timer);release();resolve();}});});
|
||||
await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id),saved=JSON.stringify(before.preparation.planner);assert(before.preparation.version===2&&before.preparation.planner.phase==='dfs','did not pause within DFS');assert(before.preparation.planner.sourceToken===before.source.sha256,'checkpoint lost source binding');assert(before.committedSourceKeys.length===0,'preparation published content prematurely');await engine.close();engine=await openProjectEngine(options);
|
||||
assert(JSON.stringify(engine.imports.getJobs().get(id).preparation.planner)===saved,'IndexedDB changed the DFS checkpoint');let resumed=false;const release=engine.editor.subscribe((_,snapshot)=>{const planner=snapshot.runtime.imports.get(id)?.preparation?.planner;if(planner){assert(planner.steps>=before.preparation.steps,'resume regressed the cursor');assert(!['selection','canvases','members','roots','seed'].includes(planner.phase),'resume repeated an earlier preparation phase');resumed=true;}});
|
||||
await engine.imports.resume(id);await engine.settle();release();const job=engine.imports.getJobs().get(id);assert(resumed&&job.status==='completed'&&job.failures.length===0,'DFS recovery did not complete');assert(job.committedSourceKeys.length===502&&Object.keys(job.stableIds).length===502,'DFS recovery lost a source record');assert(document.elements.size===1002&&document.canvases.size===3,'DFS recovery duplicated or lost content');const copiedChild=job.stableIds[`canvas:${child}`];for(let i=0;i<500;i++)assert(document.elements.get(job.stableIds[`element:member-${i}`])?.ownerCanvasId===copiedChild,'DFS sibling lost its copied owner');
|
||||
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
|
||||
}
|
||||
|
||||
async function nativeImportRecoveryCase(name,assert){
|
||||
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),original=createDocument(root,[{id:root,parent:null}],[]),database=`${name}-native-import`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1]]),engine;
|
||||
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
|
||||
@@ -593,6 +609,8 @@ export async function run() {
|
||||
results.push('blocked source durability permits a bounded local prefix, fences all network writes and recovers all records after real IndexedDB resumes');
|
||||
await referenceCycleCase(name,assert);
|
||||
results.push('native frame reference cycles and copied port mappings commit atomically through protocol 2 and real IndexedDB undo/redo/reopening');
|
||||
await duplicatePlannerRecoveryCase(name,assert);
|
||||
results.push('preparation v2 persists a DFS stack/cursor in real IndexedDB and resumes 500 siblings without restarting scans or duplicating identities');
|
||||
await duplicateRecoveryCase(name,assert);
|
||||
results.push('deep copy source/mapping survive IndexedDB pause/reopen/resume, retain C to F-prime and shared assets, preserve edits and do not revive undone content');
|
||||
await nativeImportRecoveryCase(name,assert);
|
||||
|
||||
@@ -15,6 +15,6 @@ test('snapshot decoding rejects malformed, future, duplicate, invalid and trunca
|
||||
});
|
||||
test('preparation checkpoints require a matching interpreter, valid selection, unique allocations and no published progress',()=>{
|
||||
const base={...createImportJob({id:'job',projectId:'project',sourceFingerprint:'snapshot',parserVersion:1,source:{kind:'duplicate-snapshot',sha256:'a'.repeat(64),fileId:'file',byteLength:10,targetCanvasId:'P',copy:{sourceRevision:0,roots:[]}}}),status:'preparing',preparation:{version:1,registrySignature:'registry-v1',selection:[{id:'A',incarnation:2}],worldOffset:{x:32,y:32},allocationIds:['new-A'],steps:512}};validateImportJob(base);
|
||||
for(const patch of [{preparation:undefined},{preparation:{...base.preparation,version:2}},{preparation:{...base.preparation,allocationIds:['same','same']}},{preparation:{...base.preparation,selection:[]}},{cursor:1},{committedSourceKeys:['element:A']},{status:'running'}])assert.throws(()=>validateImportJob({...base,...patch}));
|
||||
for(const patch of [{preparation:undefined},{preparation:{...base.preparation,version:3}},{preparation:{...base.preparation,allocationIds:['same','same']}},{preparation:{...base.preparation,selection:[]}},{cursor:1},{committedSourceKeys:['element:A']},{status:'running'}])assert.throws(()=>validateImportJob({...base,...patch}));
|
||||
assert.throws(()=>createImportRunner({getJob:()=>base}),/import_interpreter_unavailable/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
||||
import {createDuplicatePlanner,validateDuplicateCheckpoint} from '../../src/lib/canvas-core/duplicate-checkpoint.ts';
|
||||
import {planDuplicateResumable} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:2,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal-data-not-in-checkpoint'},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},data:{},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]}});
|
||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),{...node('F','P','frame'),geometry:{...node('F').geometry,rotation:30},data:{title:'F',text:'',presentation:{contentScale:2},ports:[{id:'in',nodeId:'A',direction:'input',handle:'left'}]}},{...node('A','S','image'),data:{title:'A',text:'',assetId:'shared',runId:'not-copied'}},{...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75}}},node('B','T'),node('D','T'),edge('incoming','C','F'),edge('deep','C','B'),edge('internal','B','D','T'),edge('across','A','B','S'),edge('outgoing','F','C')]);
|
||||
const binding={sourceToken:'source-sha',schemaToken:'schema-v1'},targets=(ids=['F','A'])=>ids.map(id=>({id,incarnation:document.elements.get(id).incarnation})),drain=planner=>{for(let i=0;i<100000;i++)if(planner.step())return planner.result();throw Error('planner did not terminate');};
|
||||
|
||||
test('each durable planning phase resumes with the same deep-copy plan and no repeated identity allocation',()=>{
|
||||
const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done'];
|
||||
for(const phase of phases){let next=0,planner=createDuplicatePlanner(document,registry,targets(),{createId:()=>`new-${++next}`},binding),checkpoint;
|
||||
for(let i=0;i<10000;i++){const done=planner.step(),state=planner.checkpoint();if(state.phase===phase&&(phase!=='emit'||state.emitted>0)&&(phase!=='emit-edges'||state.emittedEdges>0)){checkpoint=state;break;}if(done)break;}
|
||||
assert.ok(checkpoint,`missing ${phase} checkpoint`);validateDuplicateCheckpoint(JSON.parse(JSON.stringify(checkpoint)));const before=JSON.stringify(checkpoint),allocated=next,source=createDocument(document.projectId,[...document.canvases.values()],[...document.elements.values()]),resumed=createDuplicatePlanner(source,registry,targets(),{createId:()=>`new-${++next}`},{...binding,checkpoint:JSON.parse(before)}),actual=drain(resumed);let reference=0;const expected=planDuplicate(document,registry,targets(),{createId:()=>`new-${++reference}`});assert.deepEqual(actual,expected,phase);assert.equal(next,reference,`identity replay in ${phase}`);assert.equal(JSON.stringify(checkpoint),before);assert.ok(next>=allocated);assert.ok(!before.includes('literal-data-not-in-checkpoint'));
|
||||
}
|
||||
});
|
||||
|
||||
test('nested DFS expansion checkpoints preserve pending siblings, selected edges and explicit stable mappings',()=>{
|
||||
for(const selection of [['F','C','incoming'],['A','internal'],['incoming']]){let reference=0;const selected=targets(selection),expected=planDuplicate(document,registry,selected,{createId:()=>`copy-${++reference}`}),mapping={elements:expected.elementIds,canvases:expected.canvasIds};let planner=createDuplicatePlanner(document,registry,selected,{mapping,createId:()=>{throw Error('allocated despite complete mapping');}},binding),state;
|
||||
for(let i=0;i<10000;i++){const done=planner.step();state=planner.checkpoint();if(state.expanding?.index>=0||done)break;}
|
||||
assert.deepEqual(drain(createDuplicatePlanner(document,registry,selected,{mapping,createId:()=>{throw Error('reallocated');}},{...binding,checkpoint:state})),expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('checkpoint bindings, shape and source identities reject incompatible state before producing a plan',()=>{
|
||||
const planner=createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},binding),state=planner.checkpoint();for(const patch of [{sourceToken:'different'},{schemaToken:'different'}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,...patch,checkpoint:state}),/binding_mismatch/);
|
||||
for(const patch of [{version:2},{phase:'future'},{cursor:-2},{elementIds:[['F','C']]},{work:[{kind:'element',id:'missing'}]}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,checkpoint:{...state,...patch}}));assert.throws(()=>planner.result(),/copy_plan_not_ready/);
|
||||
});
|
||||
|
||||
test('cooperative resume checkpoints advance semantic cursors, yield and stop cleanly after cancellation',async()=>{
|
||||
const controller=new AbortController();let saved,ids=0,yields=0;await assert.rejects(planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},binding,{maxStepsPerSlice:1,yieldTask:async()=>{yields++;},checkpoint:async state=>{if(state.elementIds.length>=2){saved=state;controller.abort();}},signal:controller.signal}),/copy_aborted/);assert.ok(yields>0);assert.ok(saved.elementIds.length>=2);const old=ids,plan=await planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},{...binding,checkpoint:saved},{maxStepsPerSlice:2});let reference=0;assert.deepEqual(plan,planDuplicate(document,registry,targets(),{createId:()=>`async-${++reference}`}));assert.equal(ids,reference);assert.ok(ids>=old);
|
||||
});
|
||||
@@ -127,11 +127,11 @@ test('read-only policy rejects copy/import admission without storing a source or
|
||||
});
|
||||
|
||||
|
||||
test('preparing copy checkpoints source and allocated identities, reopens paused and reconstructs the same mapping',async(t)=>{
|
||||
test('preparing copy resumes saved planning cursors and allocated identities without repeating earlier phases',async(t)=>{
|
||||
const f=fixture(),save=f.storage.commit;let durableCuts=0;f.storage.commit=async(...args)=>{durableCuts++;return save(...args);};let engine=await f.open();try{
|
||||
await seedCopy(engine);assert.equal(engine.editor.transaction(tx=>{for(let i=0;i<1000;i++)tx.dispatch({type:'element.create',element:copyNode(`extra-${i}`,'child')});}).status,'applied');await engine.settle();
|
||||
const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>(snapshot.runtime.imports.get(id).preparation?.allocationIds.length??0)>0);await engine.imports.pause(id);await engine.settle();const checkpoint=engine.imports.getJobs().get(id);assert.equal(checkpoint.status,'paused');assert.equal(checkpoint.source.kind,'duplicate-snapshot');assert.ok(checkpoint.preparation.allocationIds.length>0);assert.ok(checkpoint.preparation.steps>0);assert.equal(checkpoint.committedSourceKeys.length,0);decodeRecovery(f.image(),registry);
|
||||
engine.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'new source title'}});await engine.settle();await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');assert.deepEqual(engine.imports.getJobs().get(id).preparation.allocationIds,checkpoint.preparation.allocationIds);await engine.imports.resume(id);await engine.settle();
|
||||
const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>(snapshot.runtime.imports.get(id).preparation?.allocationIds.length??0)>0);await engine.imports.pause(id);await engine.settle();const checkpoint=engine.imports.getJobs().get(id);assert.equal(checkpoint.status,'paused');assert.equal(checkpoint.source.kind,'duplicate-snapshot');assert.ok(checkpoint.preparation.allocationIds.length>0);assert.ok(checkpoint.preparation.steps>0);assert.equal(checkpoint.preparation.version,2);assert.ok(checkpoint.preparation.planner);assert.equal(checkpoint.preparation.planner.sourceToken,checkpoint.source.sha256);assert.equal(checkpoint.committedSourceKeys.length,0);decodeRecovery(f.image(),registry);
|
||||
engine.editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'new source title'}});await engine.settle();await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');assert.deepEqual(engine.imports.getJobs().get(id).preparation.allocationIds,checkpoint.preparation.allocationIds);assert.deepEqual(engine.imports.getJobs().get(id).preparation.planner,checkpoint.preparation.planner);const observed=[],stop=engine.editor.subscribe((_,snapshot)=>{const state=snapshot.runtime.imports.get(id)?.preparation?.planner;if(state)observed.push({phase:state.phase,steps:state.steps});});await engine.imports.resume(id);await engine.settle();stop();assert.ok(observed.some(state=>state.steps>checkpoint.preparation.steps));assert.ok(observed.every(state=>state.steps>=checkpoint.preparation.steps&&!['selection','canvases','members','roots','seed','dfs','edges'].includes(state.phase)));await engine.settle();
|
||||
const job=engine.imports.getJobs().get(id);assert.equal(job.status,'completed');assert.deepEqual(Object.values(job.stableIds).slice(0,checkpoint.preparation.allocationIds.length),checkpoint.preparation.allocationIds);assert.equal(engine.getSnapshot().document.elements.get(job.stableIds['element:F']).data.title,'F');assert.equal(engine.getSnapshot().document.elements.get('F').data.title,'new source title');assert.equal(job.preparation,undefined);assert.equal(f.errors.length,0);t.diagnostic(JSON.stringify({imported:job.committedSourceKeys.length,durableCuts,requests:f.requests.length}));
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
@@ -193,3 +193,8 @@ test('late source digest cannot revive a cancelled job or publish into a held re
|
||||
test('legacy unfinished sources remain readable but cannot acquire a new content identity silently',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();await engine.close();const image=f.image(),job=image.payload.runtime.imports.find(([key])=>key===id)[1];job.version=2;delete job.source.sha256;delete job.source.integrityVersion;const sent=f.requests.length;engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).error,'import_source_integrity_unavailable');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.requests.length,sent);decodeRecovery(f.image(),registry);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
test('legacy preparation version 1 still replays its reserved identities instead of allocating replacements',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();await engine.close();const job=f.image().payload.runtime.imports.find(([key])=>key===id)[1];job.preparation.version=1;job.preparation.steps=1;job.preparation.allocationIds=['legacy-frame-id'];delete job.preparation.planner;engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:F'],'legacy-frame-id');assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user