feat(canvas): dissolve owning canvases without deleting their contents

This commit is contained in:
2026-11-11 18:41:26 +08:00
parent b05058a955
commit 15ee91772d
7 changed files with 174 additions and 3 deletions
+20
View File
@@ -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
+46
View File
@@ -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<string,Json>;
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});
}
+6
View File
@@ -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<Runtime = undefined>(options: EditorOptions<Runtime
if (!canvas) throw fault('canvas_not_loaded', [command.canvasId]);
changes = equal(canvas.parent, command.parent) ? [] : [{ kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: command.parent } }];
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
} else if(command.type==='canvas.dissolve'){
const dissolved=planDissolveCanvas(document,options.registry,command.canvasId,command.entry);changes=dissolved.changes;
for(const member of dissolved.commands){
const allowed=options.policy?.(member,document);if(allowed&&!allowed.enabled)throw fault(allowed.code,allowed.targets);
}
} else if (command.type === 'element.create') {
const element = command.element;
if (document.elements.has(element.id) || document.tombstones.has(element.id)) throw fault('duplicate_identity', [element.id]);
+1
View File
@@ -29,6 +29,7 @@ export type Command =
| { readonly type: 'element.move'; readonly target: Target; readonly canvasId: Id; readonly geometry?: Geometry }
| { readonly type: 'canvas.create'; readonly canvas: CanvasRecord }
| { readonly type: 'canvas.reparent'; readonly canvasId: Id; readonly parent: CanvasRecord['parent'] }
| { readonly type: 'canvas.dissolve'; readonly canvasId: Id; readonly entry: Target }
| { readonly type: 'changes.apply'; readonly changes: readonly Change[] };
export type Origin = 'user' | 'undo' | 'redo' | 'remote' | 'ack' | 'replay' | 'asset-resolution' | 'migration';
export type Rejected = { readonly status: 'rejected'; readonly code: string; readonly targets: readonly Id[] };
@@ -31,6 +31,7 @@ export function structuralCommand(command:Command,document:Document,registry:Reg
if(command.type==='element.create'){const element=command.element;return born(state,element.id)&&!source.document.tombstones.has(element.id)&&ownerReady(state,source,document,element.ownerCanvasId)&&registry.resolve(element)==='known'&&safeData(element.data,registry,element.componentId,document,source.document,undefined,assets)?yes:deny;}
if(command.type==='canvas.create'){const canvas=command.canvas,parent=canvas.parent;return !source.versions.has(canvas.id)&&!!parent&&born(state,parent.entryElementId)&&ownerReady(state,source,document,parent.canvasId)?yes:deny;}
if(command.type==='canvas.reparent')return (!source.versions.has(command.canvasId)||sourceClosed(state,source))&&!!command.parent&&ownerReady(state,source,document,command.parent.canvasId)?yes:deny;
if(command.type==='canvas.dissolve'){const canvas=document.canvases.get(command.canvasId);return sourceClosed(state,source)&&canvas?.parent&&ownerReady(state,source,document,canvas.id)&&ownerReady(state,source,document,canvas.parent.canvasId)?yes:deny;}
if(command.type==='element.remove'||command.type==='element.move'){const element=document.elements.get(command.target.id);return element&&(born(state,element.id)||sourceClosed(state,source))&&ownerReady(state,source,document,element.ownerCanvasId)&&(command.type!=='element.move'||ownerReady(state,source,document,command.canvasId))?yes:deny;}
if(command.type==='connection.endpoints'){const element=document.elements.get(command.target.id);return element&&ownerReady(state,source,document,element.ownerCanvasId)?yes:deny;}
if(command.type==='element.data'&&(born(state,command.target.id)||sourceClosed(state,source)||state.version===6&&assetPatch(command.patch,registry,document.elements.get(command.target.id)?.componentId??'')&&ownerReady(state,source,document,document.elements.get(command.target.id)?.ownerCanvasId??''))){const element=document.elements.get(command.target.id);return element&&registry.resolve(element)==='known'&&safeData({...element.data,...command.patch},registry,element.componentId,document,source.document,element.data,assets)?yes:deny;}
+89
View File
@@ -0,0 +1,89 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createEditor,createRegistry,builtinComponents,defineComponent,movable,editable,validateDocument,applyChanges,canvasTransform,toWorld,unitScale} from '../../src/lib/canvas-core/index.ts';
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
import {loadingCommand} from '../../src/lib/canvas-runtime/loading.ts';
const node=(id,owner='P',componentId='text')=>({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();}
});
+11 -3
View File
@@ -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();}