diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index dbffd9a..4ff039b 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -5,6 +5,7 @@ import { clone, equal, fault, freeze, readMap, reject, validJson } from './value import { canvasTransform, convertGeometry } from './coordinates.ts'; import { unitScale, withUnitScale } from './units.ts'; import { removalChanges, repairConnectionOwners } from './structure.ts'; +import { planHistoryChanges } from './history.ts'; export type EditorOptions = { registry: Registry; document: Document; createId?: () => Id; historyLimit?: number; @@ -241,24 +242,11 @@ export function createEditor(options: EditorOptions>(); - const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;}; - const remap=(change:Change):Change=>{ - if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});} - if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});} - return change; - }; - let document=state.document;const changes:Change[]=[]; - for(const original of planned){ - let change=remap(original); - if(change.kind==='element'&&!change.before&&change.after){const dead=document.tombstones.get(change.id);if(dead!==undefined&&change.after.incarnation<=dead){const mapping=generations.get(change.id)??new Map();mapping.set(change.after.incarnation,dead+1);generations.set(change.id,mapping);change=remap(change);}} - document=applyChanges(document,[change]);changes.push(change); - } - validateDocument(document,options.registry); + const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry); const entries=new Map(state.history.entries); // Later redo entries belong to the lifecycle restored by this explicit history action. // Frozen operations retain their original identities and incarnations. - if(generations.size)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)})); + if(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)})); entries.set(id,freeze({...entry,changes:direction==='undo'?invert(changes):changes})); const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) }); return publish(document, changes, direction, history, id); diff --git a/src/lib/canvas-core/history.ts b/src/lib/canvas-core/history.ts new file mode 100644 index 0000000..4bfb544 --- /dev/null +++ b/src/lib/canvas-core/history.ts @@ -0,0 +1,22 @@ +import type { Change, Document, ElementRecord, Id, Registry } from './types.ts'; +import { applyChanges, validateDocument } from './document.ts'; +import { freeze } from './values.ts'; + +/** Pure history execution, also used to validate recovery proposals before publication. */ +export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){ + const generations=new Map>(); + const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;}; + const remap=(change:Change):Change=>{ + if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});} + if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});} + return change; + }; + let document=initial;const changes:Change[]=[]; + for(const original of planned){ + let change=remap(original); + if(change.kind==='element'&&!change.before&&change.after){const dead=document.tombstones.get(change.id);if(dead!==undefined&&change.after.incarnation<=dead){const mapping=generations.get(change.id)??new Map();mapping.set(change.after.incarnation,dead+1);generations.set(change.id,mapping);change=remap(change);}} + document=applyChanges(document,[change]);changes.push(change); + } + validateDocument(document,registry); + return{document,changes:freeze(changes),remap,restored:generations.size>0}; +} diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts index bb05d8b..8c888a7 100644 --- a/src/lib/canvas-runtime/conflicts.ts +++ b/src/lib/canvas-runtime/conflicts.ts @@ -1,4 +1,6 @@ import type { Change, Document, FieldChange, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; +import { invert } from '../canvas-core/document.ts'; +import { planHistoryChanges } from '../canvas-core/history.ts'; import { equal, fault, freeze, readMap } from '../canvas-core/values.ts'; import type { CanonicalProposal, EngineSnapshot } from './contracts.ts'; import { createLedger, type Presence } from './ledger.ts'; @@ -30,14 +32,15 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni const used=new Set([...snapshot.operations.keys(),...snapshot.runtime.ledger.operations.keys(),...snapshot.runtime.ledger.requests.keys(),...snapshot.history.entries.keys(),...(snapshot.runtime.recoveries?.keys()??[])]); function unique(){const id=createId();if(!id||used.has(id))throw fault('duplicate_operation_id');used.add(id);return id;} const id=unique(),ledger=createLedger(snapshot.runtime.ledger),operations=new Map(snapshot.operations),decisions:RecoveryDecision[]=[],replacements=new Map(); - const starts=new Map>(),historyOwner=new Map(),contexts=intentContexts(snapshot.document,records.map(record=>record.intent)); + const boundaries=new Map(),starts=new Map>(),historyOwner=new Map(),contexts=intentContexts(snapshot.document,records.map(record=>record.intent)); for(const entry of snapshot.history.entries.values())for(const operationId of entry.operationIds){if(historyOwner.has(operationId))throw fault('recovery_history_ambiguous');historyOwner.set(operationId,entry.id);} let document=canonical.document,sequence=0;for(const record of snapshot.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence); for(const record of records){ const intent=record.intent,choice=choices.get(intent.id)!; if(intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[intent.id]); if(intent.changes.length&&intent.origin!=='user')throw fault('recovery_history_traversal_not_supported',[intent.id]); - const before=document; + const before=document,entryId=historyOwner.get(intent.id); + if(entryId&&!boundaries.has(entryId))boundaries.set(entryId,{before,after:before}); for(const original of intent.changes)if(original.kind==='field'){ const owner=historyOwner.get(intent.id),key=owner?JSON.stringify([owner,fieldKey(original)]):undefined; if(key&&!starts.has(key))starts.set(key,value(document,original,true)); @@ -45,6 +48,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni if(choice==='keep-local')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]); const rebased=choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]}; const changes=rebased.changes;document=rebased.document; + if(entryId)boundaries.get(entryId)!.after=document; let replacement:OperationIntent|undefined; if(choice==='keep-local'){ if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]); @@ -62,7 +66,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni replacements.set(intent.id,replacement); decisions.push(freeze({operationId:intent.id,choice,resolution:choice==='discard-local'?'discarded':replacement?'superseded':'satisfied',...(replacement?{replacementId:replacement.id}:{})})); } - const history=rewriteHistory(snapshot,records.map(record=>record.intent),replacements,starts); + const history=rewriteHistory(snapshot,records.map(record=>record.intent),replacements,starts,boundaries,registry); ledger.retire(id,decisions); for(const replacement of replacements.values())if(replacement){ const presence:Record={};for(const change of replacement.changes)if(change.kind==='field')presence[change.target.id]??='exists';else if(change.kind==='element')presence[change.id]??=change.before?'exists':'absent'; @@ -77,7 +81,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni return Object.freeze({id,baseRootRevision:snapshot.rootRevision,document,history,operations:readMap(operations),ledger:ledger.getSnapshot(),audit,resourceIds:Object.freeze([...resourceIds]),operationIds:Object.freeze([...replacements.values()].flatMap(intent=>intent?[intent.id]:[]))}); } -function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap,starts:ReadonlyMap>):HistoryState { +function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap,starts:ReadonlyMap>,boundaries:ReadonlyMap,registry:Registry):HistoryState { const entries=new Map(snapshot.history.entries),affected=new Set(pending.map(intent=>intent.id)); for(const entry of snapshot.history.entries.values()){ const intents=pending.filter(intent=>entry.operationIds.includes(intent.id));if(!intents.length)continue; @@ -87,7 +91,22 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id); continue; } - if(intents.some(intent=>intent.changes.some(change=>change.kind!=='field'||['ownerCanvasId','source','target'].includes(change.field))))throw fault('recovery_history_structure_conflict',[entry.id]); + const structural=(changes:readonly Change[])=>changes.some(change=>change.kind!=='field'||['ownerCanvasId','source','target'].includes(change.field)); + if(structural(entry.changes)||intents.some(intent=>structural(intent.changes))){ + // A grouped entry is chronological. Never splice a confirmed operation from after a pending one into its prefix. + const first=entry.operationIds.findIndex(id=>affected.has(id)); + if(entry.operationIds.slice(first).some(id=>!affected.has(id)))throw fault('recovery_history_nonprefix_ack',[entry.id]); + const oldPending=intents.flatMap(intent=>intent.changes),prefix=entry.changes.slice(0,entry.changes.length-oldPending.length); + if(entry.changes.lengthreplacements.get(intent.id)?.changes??[])]; + // The acknowledged prefix is evidence, not a fresh command. Check its exact inverse rather than broadening it to remote additions. + try{planHistoryChanges(boundary.before,invert(prefix),registry);planHistoryChanges(boundary.after,invert(changes),registry);} + catch{throw fault('recovery_history_prefix_conflict',[entry.id]);} + const operationIds=entry.operationIds.flatMap(id=>affected.has(id)?replacements.get(id)?[replacements.get(id)!.id]:[]:[id]); + if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id); + continue; + } const keys=new Map();for(const intent of intents)for(const change of intent.changes as readonly FieldChange[])keys.set(fieldKey(change),change); const fields:FieldChange[]=[]; for(const [key,example]of keys){ diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index b83e10f..2bd8b72 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -5,6 +5,24 @@ import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts'; import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts'; import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts'; +async function mixedHistoryCase(name,assert){ + const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()); + const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:300,height:200,rotation:0},data:{title:id,text:''}}); + const original=createDocument(root,[{id:root,parent:null}],[node('anchor')]),storage=createBrowserStorage({name:`${name}-mixed-history`}),calls=[];let document=original,versions=new Map([[root,1]]),reject=false,engine; + const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'mixed history lost its version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; + const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + try{ + engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.create',element:{...node('entry'),componentId:'frame'}},{groupId:'partition'});await engine.settle();const acknowledged=[...engine.getSnapshot().runtime.ledger.operations.keys()][0]; + reject=true;const edit=engine.editor.transaction(tx=>{tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member',child)});},{groupId:'partition'});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(edit.operationIds[0])); + document=applyChanges(document,[{kind:'field',target:{id:'anchor',incarnation:1},field:'data.title',before:'anchor',after:'remote',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1); + const session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));reject=false;await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options); + assert(engine.getSnapshot().history.undo.length===1,'mixed group split or lost its acknowledged prefix');assert(engine.getSnapshot().history.entries.values().next().value.operationIds.includes(acknowledged),'applied logical fact was replaced');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'old rejected bytes changed'); + assert(engine.editor.history.undo().status==='applied','mixed structural undo failed after reopening');await engine.settle();assert(!document.elements.has('entry')&&!document.canvases.has(child),'mixed undo left its acknowledged entry');assert(document.elements.get('anchor').data.title==='remote','mixed undo erased remote data'); + assert(engine.editor.history.redo().status==='applied','mixed structural redo failed');await engine.settle();assert(document.elements.get('entry').incarnation===2&&document.elements.get('member').incarnation===2,'mixed redo reused deleted identities');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===2,'mixed history restoration did not survive another reopen');assert(calls.filter(call=>call.method==='POST').length===5,'mixed recovery duplicated a write'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-mixed-history`);} +} + async function scaledContainerCase(name,assert){ const root=crypto.randomUUID(),child=crypto.randomUUID(),target=crypto.randomUUID(),deep=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()); const node=(id,owner,kind='text',scale=1)=>({id,incarnation:1,structure:'node',componentId:kind,dataVersion:1,ownerCanvasId:owner,geometry:{x:id==='g'?700:100,y:80,width:300,height:200,rotation:id==='g'?70:30},data:{title:id,text:'body',fontSize:17,...(kind==='frame'?{presentation:{contentScale:scale}}:{})}}); @@ -309,6 +327,8 @@ export async function run() { results.push('structural replan restores an atomic partition/member tree through IndexedDB, undo/archive and redo/new incarnation without changing the rejected request'); await scaledContainerCase(name,assert); results.push('scaled container units preserve nested transforms and typography through atomic protocol writes, IndexedDB reopening, undo and redo'); + await mixedHistoryCase(name,assert); + results.push('mixed acknowledged entry/pending partition history persists one undo unit, preserves original request facts and restores fresh lifecycles after reopening'); return results; } finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); } } diff --git a/tests/headless/structure-recovery.test.mjs b/tests/headless/structure-recovery.test.mjs index b5734b8..feb0cb9 100644 --- a/tests/headless/structure-recovery.test.mjs +++ b/tests/headless/structure-recovery.test.mjs @@ -165,3 +165,71 @@ test('unit metadata has a positive finite schema and tiny valid scales do not di const editor=createEditor({registry,document:base()}),before=editor.getSnapshot();for(const value of [0,-1,Infinity,NaN,'invalid']){assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{presentation:{unitScale:value}}}).status,'rejected');assert.equal(editor.getSnapshot(),before);} const next=withUnitScale({unitScale:1e-20,future:{keep:true}},1e-30);assert.equal(unitScale(next),1e-30);assert.deepEqual(next.future,{keep:true});assert.equal(withUnitScale({unitScale:.5},1),undefined); }); + + +test('mixed creation history retains its acknowledged prefix when its pending edit is kept or discarded',async()=>{ + for(const keep of [true,false]){ + const f=fixture(),engine=await f.open();f.reject=false; + engine.editor.dispatch({type:'element.create',element:node('new')},{groupId:'mixed'});await engine.settle(); + f.reject=true;engine.editor.dispatch({type:'element.data',target:target('new'),patch:{title:'pending'}},{groupId:'mixed'});await engine.settle(); + f.remote([{kind:'field',target:target('a'),field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]); + const session=await engine.prepareRecovery(),selected=choices(engine);if(!keep)for(const id of selected.keys())selected.set(id,'discard-local'); + const plan=session.plan(selected);assert.equal(plan.history.undo.length,1);f.reject=false;await session.resolve(plan);await engine.settle(); + assert.equal(f.server().elements.get('new').data.title,keep?'pending':'new'); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('new'),false);assert.equal(f.server().elements.get('a').data.title,'remote'); + assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('new').incarnation,2);assert.equal(f.server().elements.get('new').data.title,keep?'pending':'new'); + await engine.close(); + } +}); + +test('mixed acknowledged entry creation and pending canvas/member creation stay one undo unit',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})},{groupId:'partition'});await engine.settle(); + f.reject=true;engine.editor.transaction(tx=>{tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});},{groupId:'partition'});await engine.settle(); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.getSnapshot().history.undo.length,1); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('inside'),false);assert.equal(f.server().elements.has('entry'),false); + assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('entry').incarnation,2);assert.equal(f.server().elements.get('member').incarnation,2);await engine.close(); +}); + +test('mixed acknowledged edit and pending deletion undo against fresh data without losing the prefix',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack'}},{groupId:'remove'});await engine.settle(); + f.reject=true;engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'remove'});await engine.settle(); + f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote text',beforePresent:true,afterPresent:true}]); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle(); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const restored=f.server().elements.get('a');assert.equal(restored.incarnation,2);assert.equal(restored.data.title,'a');assert.equal(restored.data.text,'remote text'); + assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),false);await engine.close(); +}); + +test('an acknowledged deletion prefix can restore a new lifecycle after recovery of a pending sibling edit',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'mixed-delete'});await engine.settle(); + f.reject=true;engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'pending'}},{groupId:'mixed-delete'});await engine.settle(); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle(); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').incarnation,2);assert.equal(f.server().elements.get('b').data.title,'b'); + assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('a'),false);assert.equal(f.server().elements.get('b').data.title,'pending');await engine.close(); +}); + +test('mixed owner movement and a pending edit preserve fresh unrelated fields through undo and redo',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'},{groupId:'move'});await engine.settle(); + f.reject=true;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'move'});await engine.settle(); + f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote',beforePresent:true,afterPresent:true}]); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle(); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'root');assert.equal(f.server().elements.get('a').data.text,'remote'); + assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').ownerCanvasId,'child');assert.equal(f.server().elements.get('a').data.title,'pending');await engine.close(); +}); + +test('mixed structural history cannot silently delete remote additions or claim a newer lifecycle',async()=>{ + for(const change of ['remote-member','new-life']){ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})},{groupId:'conflict'});await engine.settle(); + f.reject=true;engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'pending'}},{groupId:'conflict'});await engine.settle(); + const current=f.server().elements.get('entry');if(change==='remote-member')f.remote([{kind:'canvas',id:'remote-canvas',before:null,after:{id:'remote-canvas',parent:{canvasId:'root',entryElementId:'entry'}}},{kind:'element',id:'remote-member',before:null,after:node('remote-member','remote-canvas')}]); + else f.remote([{kind:'element',id:'entry',before:current,after:null},{kind:'element',id:'entry',before:null,after:{...current,incarnation:2}}]); + const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close(); + } +}); + + +test('a later confirmed group operation cannot be moved before an earlier rejected structural operation',async()=>{ + const f=fixture(),submit=f.port.submit;f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=intent.changes.some(change=>change.kind==='element'&&change.id==='a');return submit(request);}; + const engine=await f.open();engine.editor.dispatch({type:'element.remove',target:target('a')},{groupId:'out-of-order'});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'confirmed later'}},{groupId:'out-of-order'});await engine.settle(); + assert.equal(f.server().elements.get('c').data.title,'confirmed later');assert.equal(pending(engine).length,1); + const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history_nonprefix_ack/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close(); +});