feat(canvas): dissolve owning canvases without deleting their contents
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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)&®istry.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&®istry.resolve(element)==='known'&&safeData({...element.data,...command.patch},registry,element.componentId,document,source.document,element.data,assets)?yes:deny;}
|
||||
|
||||
@@ -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();}
|
||||
});
|
||||
@@ -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();}
|
||||
|
||||
Reference in New Issue
Block a user