diff --git a/headless/README.md b/headless/README.md index d336f6d..594a826 100644 --- a/headless/README.md +++ b/headless/README.md @@ -52,6 +52,26 @@ an error after a staged edit leaves document, history, operations and indexes at their original Root. Progressive imports still use their declared per-record failure boundaries; this helper does not promise whole-import rollback. +### Dissolving an owning canvas + +Dispatch `{type: 'canvas.dissolve', canvasId, entry: {id, incarnation}}` with the +current entry identity. The root cannot be dissolved. The entry remains an +ordinary frame with its existing incoming/outgoing connections. Direct members +move to the parent; nested entries are rebound there, while their own members +retain their owners. Node world position, rotation, size and visual units, and +connection curve positions are preserved. References declared as canvas IDs are +redirected from the dissolved canvas to its parent; literal strings and opaque +payloads are not guessed or rewritten. + +The command requires movable known members and editable declared reference +fields. It checks the host policy for each member move/reference edit and rejects +stale entry identities, unsupported curve conversions or policy/schema failures +before publishing anything. A partial source must be completely captured before +dissolving; it never promotes only the members loaded so far. All changes publish +as one history/ledger unit and use the existing atomic protocol-2 edit path. +Undo restores the owning canvas, placements and references; redo dissolves it +again. No member/entry is deleted or given a new incarnation by this operation. + Experimental partial-model runtime: pass a validated prefix plus `loading` in `EngineOptions`. Version 1 uses recovery format 7; version 2 carries snapshot-bound direct canvas membership counts and uses format 8. Both keep the scheduler held. Version 3 diff --git a/src/lib/canvas-core/dissolve.ts b/src/lib/canvas-core/dissolve.ts new file mode 100644 index 0000000..db0caf1 --- /dev/null +++ b/src/lib/canvas-core/dissolve.ts @@ -0,0 +1,46 @@ +import type {Change,Command,Document,Id,Json,Registry,Target} from './types.ts'; +import {applyChanges,targetElement} from './document.ts'; +import {canvasTransform,convertGeometry} from './coordinates.ts'; +import {unitScale,withUnitScale} from './units.ts'; +import {copySlots} from './copy-paths.ts'; +import {repairConnectionOwners} from './structure.ts'; +import {clone,equal,fault,freeze} from './values.ts'; + +/** Remove an owning canvas, retaining its entry and promoting only direct members. */ +export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{ + const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]); + if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]); + if(canvas.parent.entryElementId!==entryTarget.id)throw fault('canvas_entry_changed',[canvasId]); + targetElement(document,entryTarget); + const parent=canvas.parent.canvasId,from=canvasTransform(document,canvasId),to=canvasTransform(document,parent),changes:Change[]=[],commands:Command[]=[]; + for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){ + if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']); + const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale); + commands.push({type:'element.move',target,canvasId:parent}); + changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent}); + if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry}); + if(!equal(presentation,element.data.presentation))changes.push({kind:'field',target,field:'data.presentation',before:element.data.presentation??null,beforePresent:Object.hasOwn(element.data,'presentation'),after:presentation??null,afterPresent:presentation!==undefined}); + } + for(const child of document.canvases.values())if(child.parent?.canvasId===canvasId)changes.push({kind:'canvas',id:child.id,before:child,after:{...child,parent:{...child.parent,canvasId:parent}}}); + changes.push({kind:'canvas',id:canvasId,before:canvas,after:null}); + const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted)); + // Only declared canvas references have meaning here. Opaque payloads are retained. + for(const element of promoted.elements.values()){ + if(registry.resolve(element)!=='known')continue; + const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[]; + if(!references.length)continue; + const data=clone(element.data) as Record; + for(const reference of references)for(const slot of copySlots(data,reference.path)){ + const value=slot.parent[slot.key];if(value===null)continue; + if(reference.many&&!Array.isArray(value))throw fault('invalid_copy_reference',[element.id]); + const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;}; + slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value); + } + for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){ + if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']); + changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]}); + commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}}); + } + } + return freeze({changes,commands}); +} diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 9391329..85708ec 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -8,6 +8,7 @@ import {createDocumentQueries,advanceDocumentQueries,withQueryCoverage,type Quer import { canvasTransform, convertGeometry } from './coordinates.ts'; import { unitScale, withUnitScale } from './units.ts'; import { removalChanges, repairConnectionOwners } from './structure.ts'; +import {planDissolveCanvas} from './dissolve.ts'; import { planHistoryChanges } from './history.ts'; import { describeHistoryTransition } from './history-transition.ts'; import { planCreationSize, validateCreationSizeEffect } from './creation-size-plan.ts'; @@ -61,6 +62,11 @@ export function createEditor(options: EditorOptions({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:120,height:80,rotation:5},data:{title:id,text:'kept'}}); +const curve={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:60},in:{x:-5,y:0},out:{x:5,y:0}}]}; +const edge=(id,source,target,owner='P',path=null)=>({id,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:owner,source:{elementId:source},target:{elementId:target},path,data:{label:'keep'}}); +const registry=createRegistry([...builtinComponents(),defineComponent({id:'references',dataVersion:1,structure:'node',validate:()=>true,features:[movable(),editable()],copy:{references:[{path:['views','*','canvas'],target:'canvas',external:'preserve'}],omit:[]}})]); +const source=()=>createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G',future:'retain'}}],[ + {...node('F','P','frame'),geometry:{x:100,y:70,width:500,height:400,rotation:33},data:{title:'F',text:'',presentation:{contentScale:2,unitScale:1.2},ports:[{id:'in',nodeId:'A'}]}}, + {...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75,unitScale:.8}}},node('A','S'),node('B','T'),node('C'), + {...node('R','P','references'),data:{views:[{canvas:'S',future:true}],literal:'S'}},edge('incoming','C','F'),edge('cross','C','B'),edge('inside','A','G','S',curve), +]); +const command={type:'canvas.dissolve',canvasId:'S',entry:{id:'F',incarnation:1}}; +const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`); +function pose(document,id){const element=document.elements.get(id),t=canvasTransform(document,element.ownerCanvasId),g=element.geometry;return{...toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width:g.width*t.scale,height:g.height*t.scale,rotation:(g.rotation+t.rotation)%360,units:unitScale(element.data.presentation)*t.scale};} +function checkPromoted(before,after){ + validateDocument(after,registry);assert.equal(after.canvases.has('S'),false);assert.deepEqual(after.elements.get('F'),before.elements.get('F'));assert.deepEqual(after.elements.get('incoming'),before.elements.get('incoming')); + assert.equal(after.elements.size,before.elements.size);assert.equal(after.elements.get('A').ownerCanvasId,'P');assert.equal(after.elements.get('G').ownerCanvasId,'P');assert.equal(after.elements.get('B').ownerCanvasId,'T');assert.deepEqual(after.elements.get('B'),before.elements.get('B'));assert.deepEqual(after.canvases.get('T').parent,{...before.canvases.get('T').parent,canvasId:'P'}); + for(const id of['A','G','B']){const a=pose(before,id),b=pose(after,id);for(const key of Object.keys(a))near(a[key],b[key]);assert.equal(after.elements.get(id).incarnation,before.elements.get(id).incarnation);} + assert.equal(after.elements.get('inside').ownerCanvasId,'P');const a=toWorld(canvasTransform(before,'S'),curve.knots[0].position),b=toWorld(canvasTransform(after,'P'),after.elements.get('inside').path.knots[0].position);near(a.x,b.x);near(a.y,b.y); + assert.equal(after.elements.get('cross').ownerCanvasId,'P');assert.deepEqual(after.elements.get('R').data,{views:[{canvas:'P',future:true}],literal:'S'}); +} + +test('dissolve promotes direct members once, preserves world poses, nested ownership, entry lines and declared references',()=>{ + const document=source(),editor=createEditor({document,registry}),before=editor.getSnapshot();let notifications=0;editor.subscribe(()=>notifications++); + assert.equal(editor.can(command).enabled,true);assert.strictEqual(editor.getSnapshot(),before); + const result=editor.dispatch(command,{label:'Dissolve partition'});assert.equal(result.status,'applied');assert.equal(result.operationIds.length,1);assert.equal(notifications,1);assert.equal(editor.getSnapshot().history.undo.length,1);checkPromoted(document,editor.getSnapshot().document); + assert.equal(editor.history.undo().status,'applied');assert.deepEqual(new Map(editor.getSnapshot().document.canvases),new Map(document.canvases));assert.deepEqual(new Map(editor.getSnapshot().document.elements),new Map(document.elements)); + assert.equal(editor.history.redo().status,'applied');checkPromoted(document,editor.getSnapshot().document);assert.equal(editor.getSnapshot().history.undo.length,1); +}); + +test('dissolve rejects root, stale entry, member policy, unsupported member and opaque curve without partial publication',()=>{ + const document=source();for(const options of[ + {command:{...command,canvasId:'P'}}, + {command:{...command,entry:{id:'F',incarnation:9}}}, + {command:{...command,entry:{id:'C',incarnation:1}}}, + {policy:command=>command.type==='element.move'&&command.target.id==='A'?{enabled:false,code:'member_locked'}:{enabled:true}}, + {policy:command=>command.type==='element.data'&&command.target.id==='R'?{enabled:false,code:'reference_locked'}:{enabled:true}}, + {document:createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='A'?{...element,componentId:'future'}:element))}, + {document:createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='inside'?{...element,path:{type:'future',preserve:true}}:element))}, + ]){ + const editor=createEditor({document:options.document??document,registry,policy:options.policy}),before=editor.getSnapshot(),queries=editor.getQueries();let notifications=0;editor.subscribe(()=>notifications++); + assert.equal(editor.dispatch(options.command??command).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.strictEqual(editor.getQueries(),queries);assert.equal(notifications,0); + } +}); + +test('an empty owning canvas dissolves without deleting its entry or allocating replacement elements',()=>{ + const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame')]),editor=createEditor({document,registry}); + assert.equal(editor.dispatch(command).status,'applied');assert.deepEqual([...editor.getSnapshot().document.elements],[...document.elements]);assert.equal(editor.getSnapshot().document.tombstones.size,0);assert.equal(editor.history.undo().status,'applied');assert.deepEqual([...editor.getSnapshot().document.canvases],[...document.canvases]); +}); + +test('reparent changes the canvas binding without recursively rewriting descendant owners and cycles remain atomic failures',()=>{ + const original=source(),editor=createEditor({document:original,registry});assert.equal(editor.dispatch({type:'canvas.reparent',canvasId:'S',parent:{canvasId:'P',entryElementId:'C'}}).status,'applied'); + const document=editor.getSnapshot().document;assert.deepEqual(document.canvases.get('S').parent,{canvasId:'P',entryElementId:'C'});for(const id of['A','G','B'])assert.equal(document.elements.get(id).ownerCanvasId,original.elements.get(id).ownerCanvasId);assert.equal(document.canvases.get('T').parent.canvasId,'S');validateDocument(document,registry); + const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'canvas.reparent',canvasId:'P',parent:{canvasId:'T',entryElementId:'B'}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before); +}); + +test('dissolve waits for the complete captured source instead of promoting a partially loaded membership',()=>{ + const full=source(),prefix=createDocument('P',[...full.canvases.values()],[...full.elements.values()].filter(element=>element.id!=='R')),versions=new Map([...full.canvases.keys()].map(id=>[id,1])),state={version:5,sourceToken:'dissolve-source',declaredCanvasIds:[...full.canvases.keys()],sourceCanvases:[...full.canvases.values()],elementOwners:Object.fromEntries([...full.elements.values()].map(element=>[element.id,element.ownerCanvasId])),canvasElementCounts:Object.fromEntries([...full.canvases.keys()].map(id=>[id,[...full.elements.values()].filter(element=>element.ownerCanvasId===id).length])),receivedElements:prefix.elements.size,totalElements:full.elements.size}; + assert.equal(loadingCommand(command,prefix,registry,state,prefix,{document:prefix,versions}).enabled,false); + assert.equal(loadingCommand(command,full,registry,{...state,receivedElements:full.elements.size},full,{document:full,versions}).enabled,true); + assert.equal(loadingCommand(command,full,registry,{...state,version:3},full,{document:full,versions}).enabled,false); +}); + +test('dissolve uses one atomic protocol request, recovers a lost ACK by its original identity, then undoes and redoes after reopen',async()=>{ + const symbolic=source(),ids=Object.fromEntries([...new Set([...symbolic.canvases.keys(),...symbolic.elements.keys()])].map((id,index)=>[id,`10000000-0000-4000-8000-${String(index+1).padStart(12,'0')}`])),inverse=Object.fromEntries(Object.entries(ids).map(([name,id])=>[id,name])); + const rename=(document,ids)=>{const map=id=>ids[id]??id;return createDocument(map(document.projectId),[...document.canvases.values()].map(canvas=>({...canvas,id:map(canvas.id),parent:canvas.parent?{...canvas.parent,canvasId:map(canvas.parent.canvasId),entryElementId:map(canvas.parent.entryElementId)}:null})),[...document.elements.values()].map(element=>({...element,id:map(element.id),ownerCanvasId:map(element.ownerCanvasId),...(element.structure==='connection'?{source:{...element.source,elementId:map(element.source.elementId)},target:{...element.target,elementId:map(element.target.elementId)}}:{}),data:{...element.data,...(element.data.ports?{ports:element.data.ports.map(port=>({...port,nodeId:map(port.nodeId)}))}:{}),...(element.data.views?{views:element.data.views.map(view=>({...view,canvas:map(view.canvas)}))}:{})}})),new Map([...document.tombstones].map(([id,incarnation])=>[map(id),incarnation])));}; + const original=rename(symbolic,ids),workspace='20000000-0000-4000-8000-000000000001';let server=original,image,owner,now=1000,lose=true;const versions=new Map([...original.canvases.keys()].map(id=>[id,1])),receipts=new Map(),calls=[]; + const model=(participants,complete=false)=>({documentFormatVersion:1,projectId:original.projectId,complete,canvases:[...server.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...server.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)])),removedCanvasIds:participants.filter(id=>!server.canvases.has(id))}); + const persistence=createFoundryPersistence({registry,workspaceId:workspace,rootId:ids.P,transport:async input=>{ + calls.push(input);if(input.method==='GET')return{status:200,body:receipts.get(input.path.split('/').at(-1))}; + const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert.equal(body.expected_versions[id],versions.get(id)??0); + server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);for(const id of participants)versions.set(id,(versions.get(id)??0)+1); + const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(participants)};receipts.set(input.requestId,receipt); + if(lose){lose=false;throw Error('lost dissolve ACK');}return{status:200,body:receipt}; + }}); + const storage={async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;this.active=false;}};},async recover(){return image&&structuredClone(image);},async commit(leader,batch){assert.equal(leader,owner);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}}; + const options={scope:{accountId:'account',workspaceId:workspace,projectId:ids.P},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage,persistence,clock:{now:()=>now,setTimer:()=>0,clearTimer(){}}};let engine=await openProjectEngine(options); + try{ + assert.equal(engine.editor.dispatch({...command,canvasId:ids.S,entry:{id:ids.F,incarnation:1}}).status,'applied');await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.outcome,'unresolved');assert.deepEqual([...request.canvasIds].map(id=>inverse[id]).sort(),['P','S','T']);assert.equal(calls.filter(call=>call.method==='POST').length,1);checkPromoted(symbolic,rename(server,inverse)); + await engine.close();now=10000;engine=await openProjectEngine(options);await engine.settle();assert.equal(calls.filter(call=>call.method==='POST').length,1);const query=calls.find(call=>call.method==='GET');assert.ok(query.path.endsWith('/'+request.id));assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,request.body);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved'); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.deepEqual(new Map(server.canvases),new Map(original.canvases));assert.deepEqual(new Map(server.elements),new Map(original.elements));await engine.close();engine=await openProjectEngine(options);assert.equal(engine.getSnapshot().history.redo.length,1); + assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();checkPromoted(symbolic,rename(server,inverse));assert.equal(calls.filter(call=>call.method==='POST').length,3); + }finally{await engine.close();} +}); diff --git a/tests/headless/foundry.integration.mjs b/tests/headless/foundry.integration.mjs index 96e0a44..a55a9e7 100644 --- a/tests/headless/foundry.integration.mjs +++ b/tests/headless/foundry.integration.mjs @@ -45,11 +45,19 @@ try{ loseNextAck=true;assert.equal(engine.editor.dispatch({type:'element.data',target:{id:sourceMember.id,incarnation:1},patch:{text:'committed before lost ACK'}}).status,'applied');await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1);assert.equal(request.phase,'waiting');const sent=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length; await engine.close();now=10_000;engine=await open();await engine.settle();const restored=engine.getSnapshot().runtime.ledger.requests.get(request.id);assert.equal(restored.body,request.body);assert.equal(restored.phase,'resolved');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+request.id)));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,sent); let live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(sourceMember.id).data.text,'committed before lost ACK'); - const anchor=node(randomUUID()),frame={...node(randomUUID()),componentId:'frame'},childId=randomUUID(),member=node(randomUUID(),childId),edge={id:randomUUID(),incarnation:1,ownerCanvasId:rootId,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:anchor.id},target:{elementId:frame.id},path:null}; - assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:anchor});tx.dispatch({type:'element.create',element:frame});tx.dispatch({type:'canvas.create',canvas:{id:childId,parent:{canvasId:rootId,entryElementId:frame.id}}});tx.dispatch({type:'element.create',element:member});tx.dispatch({type:'element.create',element:edge});}).status,'applied');await engine.settle(); + const anchor=node(randomUUID()),frame={...node(randomUUID()),componentId:'frame',geometry:{x:100,y:50,width:500,height:400,rotation:30},data:{title:'Nested source',text:'',presentation:{contentScale:2}}},childId=randomUUID(),member=node(randomUUID(),childId),nested={...node(randomUUID(),childId),componentId:'frame'},grandchildId=randomUUID(),leaf=node(randomUUID(),grandchildId),edge={id:randomUUID(),incarnation:1,ownerCanvasId:rootId,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:anchor.id},target:{elementId:frame.id},path:null}; + assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:anchor});tx.dispatch({type:'element.create',element:frame});tx.dispatch({type:'canvas.create',canvas:{id:childId,parent:{canvasId:rootId,entryElementId:frame.id}}});tx.dispatch({type:'element.create',element:member});tx.dispatch({type:'element.create',element:nested});tx.dispatch({type:'canvas.create',canvas:{id:grandchildId,parent:{canvasId:childId,entryElementId:nested.id}}});tx.dispatch({type:'element.create',element:leaf});tx.dispatch({type:'element.create',element:edge});}).status,'applied');await engine.settle(); live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(member.id).ownerCanvasId,childId);assert.ok(live.document.canvases.has(childId)); const copiedId=await engine.imports.duplicate([{id:frame.id,incarnation:1},{id:member.id,incarnation:1}],{viewCanvasId:rootId});await engine.settle();const copied=engine.imports.getJobs().get(copiedId);assert.equal(copied.status,'completed');const copiedFrame=copied.stableIds['element:'+frame.id],copiedMember=copied.stableIds['element:'+member.id];assert.ok(copiedFrame&&copiedMember); live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.ok(live.document.elements.has(copiedMember));assert.ok([...live.document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId===anchor.id&&element.target.elementId===copiedFrame));assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(copiedFrame).incarnation,2); + // Dissolve is an owning-tree edit, not recursive deletion. Lose the successful + // real DB response, reopen/query, then restore and repeat the same history unit. + loseNextAck=true;assert.equal(engine.editor.dispatch({type:'canvas.dissolve',canvasId:childId,entry:{id:frame.id,incarnation:1}}).status,'applied');await engine.settle(); + const dissolveRequest=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1),dissolveWrites=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;assert.equal(dissolveRequest.phase,'waiting');assert.equal(loseNextAck,false,'dissolve did not receive a successful real service response'); + await engine.close();now+=10_000;engine=await open();await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(dissolveRequest.id).phase,'resolved');assert.equal(engine.getSnapshot().runtime.ledger.requests.get(dissolveRequest.id).body,dissolveRequest.body);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,dissolveWrites); + live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.canvases.has(childId),false);assert.equal(live.document.elements.get(member.id).ownerCanvasId,rootId);assert.equal(live.document.elements.get(nested.id).ownerCanvasId,rootId);assert.equal(live.document.canvases.get(grandchildId).parent.canvasId,rootId);assert.equal(live.document.elements.get(leaf.id).ownerCanvasId,grandchildId);assert.deepEqual(live.document.elements.get(leaf.id).geometry,leaf.geometry);assert.equal(live.document.elements.get(frame.id).incarnation,1);assert.deepEqual(live.document.elements.get(edge.id).target,{elementId:frame.id}); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(member.id).ownerCanvasId,childId);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.canvases.has(childId),false);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle(); + live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(member.id).ownerCanvasId,childId);assert.equal(live.document.canvases.get(childId).parent.entryElementId,frame.id);assert.equal(live.document.canvases.get(grandchildId).parent.canvasId,childId);assert.equal(live.document.elements.get(leaf.id).ownerCanvasId,grandchildId); const importId=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'native',record:node('native','$target')})+'\n'+JSON.stringify({kind:'unsupported'})+'\n']),rootId);await engine.settle();const imported=engine.imports.getJobs().get(importId);assert.equal(imported.status,'partial');assert.equal(imported.committedSourceKeys.length,1); // Put a deterministic sorted tail in a different owner. Every original ID is // lower than these records, so the first real 64-record frame certifies the @@ -96,5 +104,5 @@ try{ await engine.close();adoptionOnly=false;aiReport=await verifyFoundryAI({open:()=>openProjectEngine({...targetOptions,runs:createFoundryRuns({workspaceId,rootId:targetRoot,registry,transport})}),transport,rootId:targetRoot,imageId:adoptedId,assets,calls,tick,readDisplay:async url=>{const response=await authenticatedFetch(url);assert.equal(response.status,200);return Buffer.from(await response.arrayBuffer());},advance:()=>{now+=10_000;},loseAdmission:()=>{loseNextAIAdmission=true;},loseAction:()=>{loseNextAIAction=true;}}); } assert.ok(errors.every(error=>String(error).includes('injected response loss')),'unexpected engine error: '+errors.map(String).join(',')); - console.log(JSON.stringify({status:'ok',...aiReport,migrationCoordinator:true,migrationLostAckRecovered:true,migrationRetry:true,realHttp:true,lostAckRecovered:true,atomicPartition:true,deepCopyUpperEdge:true,nativePartialReport:true,loadingImportOldCapture:true,unloadedTailDuringCopy:true,scopedImageLostAck:true,copiedPendingImageSharesUpload:true,authorizedAssetMetadata:true,originalAssetBytes:true,crossProjectAdoption:true,adoptionReopenedBytes:true,requests:calls.length})); + console.log(JSON.stringify({status:'ok',...aiReport,migrationCoordinator:true,migrationLostAckRecovered:true,migrationRetry:true,realHttp:true,lostAckRecovered:true,atomicPartition:true,deepCopyUpperEdge:true,dissolveCanvas:true,dissolveLostAckRecovered:true,dissolveUndoRedo:true,nativePartialReport:true,loadingImportOldCapture:true,unloadedTailDuringCopy:true,scopedImageLostAck:true,copiedPendingImageSharesUpload:true,authorizedAssetMetadata:true,originalAssetBytes:true,crossProjectAdoption:true,adoptionReopenedBytes:true,requests:calls.length})); }finally{await engine.close();}