From 1b29b0138b261a046bf63654f0077189d0bc0eaa Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 03:30:00 +0800 Subject: [PATCH] Replan native history events across repeated traversal and redo invalidation --- src/lib/canvas-core/editor.ts | 2 +- src/lib/canvas-core/history-transition.ts | 4 +- src/lib/canvas-runtime/conflicts.ts | 9 ++- src/lib/canvas-runtime/engine.ts | 2 +- .../history-journal-recovery.ts | 72 +++++++++++++++++++ tests/headless/browser-storage-cases.mjs | 13 ++++ tests/headless/conflicts.test.mjs | 4 +- tests/headless/structure-recovery.test.mjs | 59 +++++++++++++-- 8 files changed, 151 insertions(+), 14 deletions(-) create mode 100644 src/lib/canvas-runtime/history-journal-recovery.ts diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index f91f025..04ee364 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -168,7 +168,7 @@ export function createEditor(options: EditorOptions!equal((prepared as unknown as Record)[key],(intent as unknown as Record)[key])))throw fault('invalid_prepared_intent'); diff --git a/src/lib/canvas-core/history-transition.ts b/src/lib/canvas-core/history-transition.ts index 1344bff..0c0bd06 100644 --- a/src/lib/canvas-core/history-transition.ts +++ b/src/lib/canvas-core/history-transition.ts @@ -12,10 +12,10 @@ function splice(before:readonly T[],after:readonly T[]):Splice{ return{offset,beforeLength:before.length,afterLength:after.length,before:before.slice(offset,before.length-suffix),after:after.slice(offset,after.length-suffix)}; } /** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */ -export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id):HistoryTransition|undefined{ +export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{ const entries:EntryPatch[]=[]; for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});} - if(!entries.length&&equal(before.undo,after.undo)&&equal(before.redo,after.redo))return undefined; + if(!recordNoop&&!entries.length&&equal(before.undo,after.undo)&&equal(before.redo,after.redo))return undefined; return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries}); } export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{ diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts index 3953894..ad72cc9 100644 --- a/src/lib/canvas-runtime/conflicts.ts +++ b/src/lib/canvas-runtime/conflicts.ts @@ -6,6 +6,7 @@ import { equal, fault, freeze, readMap } from '../canvas-core/values.ts'; import type { CanonicalProposal, EngineSnapshot } from './contracts.ts'; import { createLedger, type Presence } from './ledger.ts'; import { intentContexts, rebaseIntent } from './change-rebase.ts'; +import { createHistoryJournalRecovery } from './history-journal-recovery.ts'; import { replanTraversals } from './traversal-recovery.ts'; import { operationCanvases, validateCanonicalRead } from './reconcile.ts'; import { referencedResources } from './resources.ts'; @@ -24,7 +25,7 @@ function value(document: Document, change: FieldChange, allowMissing=false): {be const documentValue = (document: Document) => ({projectId:document.projectId,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:[...document.tombstones]}); /** Pure proposal. Explicit choices cover the complete pending dependency closure; no key/body is reused. */ -export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap, registry: Registry, createId:()=>Id): ConflictPlan { +export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap, registry: Registry, createId:()=>Id,historyLimit=100): ConflictPlan { validateCanonicalRead(snapshot.runtime.acknowledged,snapshot.runtime.versions,canonical.document,canonical.versions,registry); if([...snapshot.runtime.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('recovery_waiting_for_receipts'); const records=[...snapshot.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload').sort((a,b)=>a.intent.sequence-b.intent.sequence); @@ -36,6 +37,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni const id=unique(),ledger=createLedger(snapshot.runtime.ledger),operations=new Map(snapshot.operations),decisions:RecoveryDecision[]=[],replacements=new Map(); 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);} + const journal=records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)?createHistoryJournalRecovery(snapshot,records.map(record=>record.intent),registry,historyLimit):undefined; 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)!; @@ -48,7 +50,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni if(key&&!starts.has(key))starts.set(key,value(document,original,true)); } if(choice==='keep-local'&&intent.origin==='user')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 rebased=journal?journal.project(document,intent,contexts.get(intent.id)!,choice==='keep-local'):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; @@ -66,10 +68,11 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni operations.set(replacement.id,replacement); } } + journal?.accept(intent,replacement,choice==='keep-local',before,document); 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 pending=records.map(record=>record.intent),history=pending.some(intent=>['undo','redo'].includes(intent.origin)&&intent.changes.length)?replanTraversals(snapshot,pending,replacements,new Set(decisions.filter(decision=>decision.choice==='keep-local').map(decision=>decision.operationId)),boundaries,registry):rewriteHistory(snapshot,pending,replacements,starts,boundaries,registry); + const pending=records.map(record=>record.intent),history=journal?journal.finish():pending.some(intent=>['undo','redo'].includes(intent.origin)&&intent.changes.length)?replanTraversals(snapshot,pending,replacements,new Set(decisions.filter(decision=>decision.choice==='keep-local').map(decision=>decision.operationId)),boundaries,registry):rewriteHistory(snapshot,pending,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'; diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 2c60195..b20afa3 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -331,7 +331,7 @@ export async function openProjectEngine(options: EngineOptions) { ...(conflict?{conflict}:{}), plan(choices:ReadonlyMap){ assertCurrent();if(committed)throw fault('recovery_already_committed');if(editor.getSnapshot().rootRevision!==root.rootRevision)throw fault('stale_plan'); - const plan=planConflictRecovery(root,canonical,choices,options.registry,createId);plans.add(plan);return plan; + const plan=planConflictRecovery(root,canonical,choices,options.registry,createId,options.historyLimit??100);plans.add(plan);return plan; }, async resolve(plan:ConflictPlan):Promise{ assertCurrent();if(!plans.has(plan))throw fault('invalid_recovery_plan');if(committed&&committed!==plan)throw fault('recovery_already_committed'); diff --git a/src/lib/canvas-runtime/history-journal-recovery.ts b/src/lib/canvas-runtime/history-journal-recovery.ts new file mode 100644 index 0000000..67aca91 --- /dev/null +++ b/src/lib/canvas-runtime/history-journal-recovery.ts @@ -0,0 +1,72 @@ +import type { Change, Document, FieldChange, HistoryEntry, HistoryState, Id, OperationIntent, Registry } from '../canvas-core/types.ts'; +import { applyHistoryTransition, validateHistoryTransition } from '../canvas-core/history-transition.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 { rebaseIntent, type IntentContext } from './change-rebase.ts'; +import type { EngineSnapshot } from './contracts.ts'; + +/** Native history events are reconstructed from evidence; all cursors remain private until the recovery root is accepted. */ +export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:readonly OperationIntent[],registry:Registry,limit:number){ + if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_history_limit'); + const pendingIds=new Set(pending.map(intent=>intent.id)),withChanges=pending.filter(intent=>intent.changes.length),first=withChanges.reduce((value,intent)=>Math.min(value,intent.sequence),Infinity); + const records=[...snapshot.runtime.ledger.operations.values()].filter(record=>record.intent.sequence>=first&&record.intent.changes.length).sort((a,b)=>a.intent.sequence-b.intent.sequence); + const events=records.map(record=>{const transition=record.intent.historyTransition;if(!transition)throw fault('recovery_history_transition_missing',[record.intent.id]);validateHistoryTransition(transition);return{record,transition,before:undefined as HistoryState|undefined,after:undefined as HistoryState|undefined};}); + const firstRoot=events[0]?.transition.rootRevision??Infinity; + if([...snapshot.runtime.recoveries.values()].some(audit=>(audit.historyTransition?.rootRevision??audit.baseRootRevision+1)>=firstRoot))throw fault('recovery_history_adoption_replay_required'); + for(let i=1;i();let remap:((change:Change)=>Change)|undefined; + const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap)=>Object.freeze({undo:freeze([...undo]),redo:freeze([...redo]),entries:readMap(entries)}); + const eventFor=(intent:OperationIntent)=>{const event=events[next];if(!event||event.record.intent.id!==intent.id)throw fault('recovery_history_event_order',[intent.id]);return event;}; + function addUser(intent:OperationIntent,changes:readonly Change[],operationId:Id,pending:boolean,document?:Document){ + const event=eventFor(intent),id=event.transition.entryId;if(!id)return; + const original=event.after!.entries.get(id);if(!original)throw fault('recovery_history_transition_conflict',[id]); + const entries=new Map(history.entries),undo=[...history.undo];for(const removed of history.redo)entries.delete(removed); + const prior=entries.get(id),appending=event.before!.entries.has(id); + if(appending&&(!prior||undo.at(-1)!==id)||!appending&&prior)throw fault('recovery_history_traversal_order',[id]); + let prefix=prior?.changes??[]; + if(pending&&prior&&!touched.has(id)&&document){ + if(prefix.every(change=>change.kind==='field')){ + const fields=prefix as readonly FieldChange[],last=new Map();const key=(change:FieldChange)=>JSON.stringify([change.target.id,change.target.incarnation,change.field]);for(const change of fields)last.set(key(change),change); + const keep=new Set([...last].filter(([,change])=>{const target=document.elements.get(change.target.id);if(target?.incarnation!==change.target.incarnation)return false;const field=change.field.startsWith('data.')?change.field.slice(5):undefined,value=field===undefined?(target as unknown as Record)[change.field]:target.data[field]??null;return equal(value,change.after)&&(field===undefined||Object.hasOwn(target.data,field)===(change.afterPresent??true));}).map(([key])=>key));prefix=fields.filter(change=>keep.has(key(change))); + }else try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[id]);} + } + const {changes:_changes,operationIds:_operations,...metadata}=original; + entries.set(id,freeze({...metadata,changes:[...prefix,...changes],operationIds:[...(prior?.operationIds??[]),operationId]}));if(!appending)undo.push(id);while(undo.length>limit)entries.delete(undo.shift()!);history=state(undo,[],entries);if(pending)touched.add(id); + } + function advance(intent?:OperationIntent){ + while(next>(); + const pair=(a:{id:Id;incarnation:number}|null,b:{id:Id;incarnation:number}|null)=>{if(a&&b&&a.id===b.id&&a.incarnation!==b.incarnation){const values=maps.get(a.id)??new Map();values.set(a.incarnation,b.incarnation);maps.set(a.id,values);}}; + before.changes.forEach((a,index)=>{const b=after.changes[index];if(a.kind==='field'&&b?.kind==='field')pair(a.target,b.target);else if(a.kind==='element'&&b?.kind==='element'){pair(a.before,b.before);pair(a.after,b.after);}}); + return(change:Change):Change=>{const record=(value:T|null)=>value?{...value,incarnation:maps.get(value.id)?.get(value.incarnation)??value.incarnation}:null;if(change.kind==='field')return freeze({...change,target:record(change.target)!});if(change.kind==='element')return freeze({...change,before:record(change.before),after:record(change.after)});return change;}; + } + return{ + project(document:Document,intent:OperationIntent,context:IntentContext,keep:boolean){ + if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined; + if(['undo','redo'].includes(intent.origin)){ + if(!id||history[intent.origin==='undo'?'undo':'redo'].at(-1)!==id)throw fault('recovery_history_traversal_order',id?[id]:[]);const entry=history.entries.get(id)!; + if(keep&&touched.has(id)){try{const planned=planHistoryChanges(document,intent.origin==='undo'?invert(entry.changes):entry.changes,registry);remap=planned.remap;return planned;}catch{throw fault('recovery_history_traversal_conflict',[id]);}} + if(!keep){try{planHistoryChanges(document,intent.origin==='undo'?invert(entry.changes):entry.changes,registry);}catch{throw fault('recovery_history_traversal_conflict',[id]);}} + else remap=incarnationRemap(event.before!.entries.get(id)!,event.after!.entries.get(id)!); + } + return keep?rebaseIntent(document,intent,context,registry):{document,changes:[] as readonly Change[]}; + }, + accept(intent:OperationIntent,replacement:OperationIntent|undefined,keep:boolean,before:Document,after:Document){ + if(!intent.changes.length)return;const event=eventFor(intent),id=event.transition.entryId; + if(intent.origin==='user'){if(replacement)addUser(intent,replacement.changes,replacement.id,true,before);} + else if(['undo','redo'].includes(intent.origin)&&id&&keep){ + const entries=new Map(history.entries),undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo,entry=entries.get(id)!;source.pop(); + if(replacement){const changes=intent.origin==='undo'?invert(replacement.changes):replacement.changes;try{planHistoryChanges(after,intent.origin==='undo'?changes:invert(changes),registry);}catch{throw fault('recovery_history_traversal_conflict',[id]);}if(remap)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,operationIds:[...entry.operationIds,replacement.id]}));destination.push(id);}else entries.delete(id); + history=state(undo,redo,entries);touched.add(id); + } + next++; + }, + finish(){advance();return history;}, + }; +} diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 75e926a..edd9bee 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -5,6 +5,17 @@ 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 journalRecoveryCase(name,assert){ + const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('a'),{...node('entry'),componentId:'frame'},node('c',child)]),storage=createBrowserStorage({name:`${name}-journal-recovery`}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine; + const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + const 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),'journal recovery lost its version condition');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)+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(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + try{ + engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'edited'}});await engine.settle();reject=true;const undo=engine.editor.history.undo();await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(undo.operationIds[0]));reject=false;const branch=engine.editor.dispatch({type:'element.data',target:{id:'c',incarnation:1},patch:{text:'confirmed branch'}});await engine.settle();assert(engine.getSnapshot().history.redo.length===0,'branch did not invalidate redo');await engine.close();engine=await openProjectEngine(options); + const session=await engine.prepareRecovery(),plan=session.plan(new Map([[undo.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(document.elements.get('a').data.text===''&&document.elements.get('c').data.text==='confirmed branch','confirmed branch was lost or pending undo was not restored');assert(engine.getSnapshot().history.undo.length===1&&engine.getSnapshot().history.redo.length===0,'journal recovery retained an invalidated redo');assert(engine.getSnapshot().history.entries.values().next().value.operationIds.includes(branch.operationIds[0]),'confirmed branch logical identity changed');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'journal adoption changed the rejected request');assert(engine.editor.history.undo().status==='applied','confirmed branch undo failed');await engine.settle();assert(document.elements.get('c').data.text==='','branch inverse used an incorrect baseline');assert(engine.editor.history.redo().status==='applied','confirmed branch redo failed');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===6,'confirmed content was sent twice during event replay'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-journal-recovery`);} +} + async function historyEvidenceCase(name,assert){ const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument(root,[{id:root,parent:null}],[node]),storage=createBrowserStorage({name:`${name}-history-evidence`}),calls=[];let engine; const transport=async request=>{calls.push(request);return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};}; @@ -359,6 +370,8 @@ export async function run() { results.push('rejected partition undo recovers archived scopes and history through IndexedDB; redo/undo advance lifecycles without repeating the old request'); await historyEvidenceCase(name,assert); results.push('history transition evidence survives redo invalidation and IndexedDB reopening, reconstructs prior stacks without document mutation and never enters service payloads'); + await journalRecoveryCase(name,assert); + results.push('native history events recover a rejected undo after a later confirmed branch cleared redo; IndexedDB reopening retains the branch identity and sends its content once'); return results; } finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); } } diff --git a/tests/headless/conflicts.test.mjs b/tests/headless/conflicts.test.mjs index 14ced2d..e649bf4 100644 --- a/tests/headless/conflicts.test.mjs +++ b/tests/headless/conflicts.test.mjs @@ -78,9 +78,9 @@ test('recovery publication failure keeps one plan and blocks sends until the exa f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,2);assert.ok(engine.getDurability().durableRootRevision>revision);assert.equal(engine.getSnapshot().runtime.recoveries.size,1);await engine.close(); }); -test('incomplete choices and mixed user/traversal recovery require explicit handling',async()=>{ +test('incomplete choices reject while recorded user/traversal recovery can be planned',async()=>{ const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();const session=await engine.prepareRecovery();assert.throws(()=>session.plan(new Map()),/recovery_choices_incomplete/);await session.cancel(); - assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.throws(()=>undoSession.plan(choices(engine)),/recovery_history_mixed_traversal_not_supported/);await undoSession.cancel();await engine.close(); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.ok(undoSession.plan(choices(engine)).history.redo.length===1);await undoSession.cancel();await engine.close(); }); test('retirement rejects unknown work and incomplete dependency closures without mutation',()=>{ diff --git a/tests/headless/structure-recovery.test.mjs b/tests/headless/structure-recovery.test.mjs index 5658953..a71c603 100644 --- a/tests/headless/structure-recovery.test.mjs +++ b/tests/headless/structure-recovery.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createDocument,createRegistry,builtinComponents,applyChanges,validateDocument,createEditor,canvasTransform,toWorld,convertGeometry,defineComponent,movable,resizable,rotatable,unitScale,withUnitScale } from '../../src/lib/canvas-core/index.ts'; import { projectCanvas } from '../../src/lib/canvas-bindings/projection.ts'; import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts'; +import { planConflictRecovery } from '../../src/lib/canvas-runtime/conflicts.ts'; import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts'; const registry=createRegistry(builtinComponents()),geometry={x:100,y:60,width:100,height:80,rotation:0}; const node=(id,owner='root',extra={})=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry,...extra}); @@ -15,7 +16,7 @@ const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values( const choices=engine=>new Map(pending(engine).map(record=>[record.intent.id,'keep-local'])); function fixture(original=base()){ let server=original,versions=new Map([...original.canvases.keys()].map(id=>[id,1])),image,owner;const calls=[]; - const f={reject:true,calls,storage:{async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;}};},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(){}},port:{compile(intent,id,snapshot){return{payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const {intent}=JSON.parse(request.body);for(const[id,version]of Object.entries(request.expectedVersions))assert.equal(version,versions.get(id)??0);const after=applyChanges(server,intent.changes);validateDocument(after,registry);for(const change of intent.changes){const ids=change.kind==='canvas'?[change.id]:change.kind==='element'?[change.before?.ownerCanvasId,change.after?.ownerCanvasId]:[server.elements.get(change.target.id)?.ownerCanvasId,after.elements.get(change.target.id)?.ownerCanvasId];for(const id of ids)if(id)assert.ok(request.canvasIds.includes(id));}server=after;for(const id of request.canvasIds)versions.set(id,(versions.get(id)??0)+1);return{outcome:'applied',canonical:{id:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions};},async read(){return{document:server,versions};}},remote(changes){server=applyChanges(server,changes);validateDocument(server,registry);for(const id of new Set([...versions.keys(),...server.canvases.keys()]))versions.set(id,(versions.get(id)??0)+1);},image:()=>structuredClone(image),server:()=>server,async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port});}};return f; + const f={reject:true,calls,storage:{async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;}};},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(){}},port:{compile(intent,id,snapshot){return{payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const {intent}=JSON.parse(request.body);for(const[id,version]of Object.entries(request.expectedVersions))assert.equal(version,versions.get(id)??0);const after=applyChanges(server,intent.changes);validateDocument(after,registry);for(const change of intent.changes){const ids=change.kind==='canvas'?[change.id]:change.kind==='element'?[change.before?.ownerCanvasId,change.after?.ownerCanvasId]:[server.elements.get(change.target.id)?.ownerCanvasId,after.elements.get(change.target.id)?.ownerCanvasId];for(const id of ids)if(id)assert.ok(request.canvasIds.includes(id));}server=after;for(const id of request.canvasIds)versions.set(id,(versions.get(id)??0)+1);return{outcome:'applied',canonical:{id:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions};},async read(){return{document:server,versions};}},remote(changes){server=applyChanges(server,changes);validateDocument(server,registry);for(const id of new Set([...versions.keys(),...server.canvases.keys()]))versions.set(id,(versions.get(id)??0)+1);},image:()=>structuredClone(image),server:()=>server,async open(extra={}){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,...extra});}};return f; } const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`); @@ -288,12 +289,60 @@ test('discarding a rejected traversal never broadens its old inverse over a remo f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local']))),/recovery_history_traversal_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close(); }); -test('mixed user and traversal recovery and repeated traversals require additional history evidence',async()=>{ +test('history evidence recovers mixed edits, repeated traversal and a cleared redo branch',async()=>{ for(const mode of ['mixed','repeat','orphan']){ const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true; if(mode==='mixed')engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'pending'}}); - engine.editor.history.undo();if(mode==='repeat')engine.editor.history.redo(); - if(mode==='orphan'){engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'later confirmed'}});} - await engine.settle();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(); + engine.editor.history.undo();if(mode==='repeat')engine.editor.history.redo();if(mode==='orphan')engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'branch'}});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(f.server().elements.get('a').data.title,mode==='orphan'?'a':'edited');assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(f.server().elements.get('c').data.title,mode==='orphan'?'branch':'c'); + if(mode==='mixed'){assert.equal(engine.getSnapshot().history.redo.length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('b').data.title,'pending');} + else if(mode==='repeat'){assert.equal(engine.getSnapshot().history.redo.length,0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');} + else{assert.equal(engine.getSnapshot().history.redo.length,0);assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('c').data.title,'c');} + await engine.close(); } }); + + +test('repeated undo-redo uses the reviewed remote baseline instead of resurrecting the old after value',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.redo();await engine.settle(); + f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',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(f.server().elements.get('a').data.title,'remote');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');await engine.close(); +}); + +test('discarding a pending undo then keeping a new branch preserves the original edit and a legal two-entry undo stack',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;const undo=engine.editor.history.undo();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'branch'}});await engine.settle();const session=await engine.prepareRecovery(),selected=choices(engine);selected.set(undo.operationIds[0],'discard-local');const plan=session.plan(selected);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');assert.equal(f.server().elements.get('b').data.title,'branch');assert.equal(engine.getSnapshot().history.undo.length,2); + engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('b').data.title,'b');engine.editor.history.undo();await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');await engine.close(); +}); + +test('pending create-undo-redo and later edits preserve generations and all history dependencies',async()=>{ + const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new')});engine.editor.history.undo();engine.editor.history.redo();engine.editor.dispatch({type:'element.data',target:{id:'new',incarnation:2},patch:{title:'edited second life'}});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(f.server().elements.get('new').incarnation,2);assert.equal(f.server().elements.get('new').data.title,'edited second life'); + for(let i=0;i<2;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}assert.equal(f.server().elements.has('new'),false);for(let i=0;i<2;i++){assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();}assert.equal(f.server().elements.get('new').incarnation,3);assert.equal(f.server().elements.get('new').data.title,'edited second life');await engine.close(); +}); + +test('history-disabled user edits carry explicit no-op evidence and do not clear a recovered redo stack',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();const change=engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'untracked'}},{history:false});await engine.settle();assert.equal(engine.getSnapshot().operations.get(change.operationIds[0]).historyTransition.entryId,undefined);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('b').data.title,'untracked');assert.equal(engine.getSnapshot().history.redo.length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'edited');await engine.close(); +}); + + +test('a later confirmed branch is replayed as a history event without applying its content twice',async()=>{ + for(const keep of [true,false]){ + const f=fixture(),engine=await f.open();f.port.verifyApplied=(request,receipt)=>{assert.equal(receipt.id,request.id);const versions=new Map(request.canvasIds.map(id=>[id,request.expectedVersions[id]+1]));return{versions,assertCanonical(proposal){for(const[id,version]of versions)assert.ok(proposal.versions.get(id)>=version);assert.equal(proposal.document.elements.get('c').data.title,'confirmed branch');}};}; + f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;const undo=engine.editor.history.undo();await engine.settle();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'confirmed branch'}});await engine.settle(); + const session=await engine.prepareRecovery(),plan=session.plan(new Map([[undo.operationIds[0],keep?'keep-local':'discard-local']]));await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,keep?'a':'edited');assert.equal(f.server().elements.get('c').data.title,'confirmed branch');assert.equal(engine.getSnapshot().history.undo.length,keep?1:2);assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.changes.some(change=>change.kind==='field'&&change.target.id==='c')).length,1); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('c').data.title,'c');await engine.close(); + } +}); + +test('native event recovery refuses future, missing or unproved legacy transitions without publishing',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.redo();await engine.settle();const source=engine.getSnapshot(),canonical=await f.port.read(),selected=choices(engine); + for(const mode of ['future','missing','legacy']){ + const operations=new Map(source.operations),records=new Map(source.runtime.ledger.operations);for(const[id,record]of records){if(!selected.has(id))continue;const intent={...record.intent};if(mode==='future')intent.historyTransition={version:2,opaque:'keep'};else if(mode==='legacy'||id===[...selected.keys()].at(-1))delete intent.historyTransition;operations.set(id,{...operations.get(id),historyTransition:intent.historyTransition});records.set(id,{...record,intent});} + const snapshot={...source,operations,runtime:{...source.runtime,ledger:{...source.runtime.ledger,operations:records}}};assert.throws(()=>planConflictRecovery(snapshot,canonical,selected,registry,()=>crypto.randomUUID()),mode==='future'?/history_transition_interpreter_unavailable/:/recovery_history/);assert.equal(engine.getSnapshot(),source); + } + await engine.close(); +}); + + +test('history event replay uses the engine capacity when discarding undo retains an extra prior entry',async()=>{ + const f=fixture(),engine=await f.open({historyLimit:2});f.reject=false;const first=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'second'}});await engine.settle();f.reject=true;const undo=engine.editor.history.undo();engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'branch'}});await engine.settle();const session=await engine.prepareRecovery(),selected=choices(engine);selected.set(undo.operationIds[0],'discard-local');const plan=session.plan(selected);assert.equal(plan.history.undo.length,2);assert.equal(plan.history.entries.has(first.historyEntryId),false);f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('c').data.title,'c');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(engine.editor.history.undo().code,'history_empty');await engine.close(); +});