diff --git a/src/components/canvas/headless-recovery-dialog.tsx b/src/components/canvas/headless-recovery-dialog.tsx index 91068c2..3932f11 100644 --- a/src/components/canvas/headless-recovery-dialog.tsx +++ b/src/components/canvas/headless-recovery-dialog.tsx @@ -21,6 +21,8 @@ function message(error:unknown){ if(code==='stale_endpoint')return '连线所指元素已删除或进入新的生命周期,请重新选择端点。'; if(code==='unsupported_presentation_units'||code==='invalid_presentation_units')return '这个元素的展示单位暂时无法转换,原内容已保留,请确认展示格式后重试。'; if(code==='unsupported_coordinate_path')return '这条曲线使用未知坐标格式,当前无法安全转换归属,原路径仍已保留。'; + if(code==='history_replay_interpreter_unavailable'||code==='history_transition_interpreter_unavailable'||code==='operation_interpreter_unavailable')return '这份修改或恢复记录来自当前无法解析的版本,原内容和请求已保留。请更新应用后重试。'; + if(code==='recovery_history_adoption_evidence_missing')return '这次恢复缺少完整历史步骤,暂时无法安全继续处理;原内容和请求已保留。'; if(code==='recovery_history_traversal_order')return '这些撤销或重做有先后顺序,不能跳过前一项继续处理后一项。请重新选择。'; if(code==='recovery_history_traversal_conflict')return '服务端内容与这项历史记录已有冲突,暂时无法安全调整撤销或重做;原内容和历史均已保留。'; if(code?.startsWith('recovery_history')||code==='recovery_record_replacement_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。'; diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts index 92c4cd1..5150984 100644 --- a/src/lib/canvas-runtime/conflicts.ts +++ b/src/lib/canvas-runtime/conflicts.ts @@ -38,7 +38,8 @@ 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 content=records.filter(record=>record.intent.changes.length),hasEvidence=content.length>0&&content.every(record=>record.intent.historyTransition)||records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition); + const adopted=new Set([...snapshot.runtime.recoveries.values()].filter(audit=>audit.historyReplay!==undefined).flatMap(audit=>audit.decisions.flatMap(decision=>decision.replacementId?[decision.replacementId]:[]))); + const content=records.filter(record=>record.intent.changes.length),hasEvidence=content.length>0&&content.every(record=>record.intent.historyTransition)||records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)||content.some(record=>adopted.has(record.intent.id)); const journal=hasEvidence?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){ diff --git a/src/lib/canvas-runtime/history-event-source.ts b/src/lib/canvas-runtime/history-event-source.ts new file mode 100644 index 0000000..4089dd9 --- /dev/null +++ b/src/lib/canvas-runtime/history-event-source.ts @@ -0,0 +1,34 @@ +import type { OperationIntent } from '../canvas-core/types.ts'; +import { validateHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts'; +import { fault } from '../canvas-core/values.ts'; +import type { EngineSnapshot } from './contracts.ts'; +import type { OperationRecord } from './ledger.ts'; +import { validateHistoryReplay, type HistoryReplay } from './history-replay.ts'; + +export type HistorySourceEvent={readonly record:OperationRecord|undefined;readonly transition:HistoryTransition}; +export type HistoryEventSource={readonly events:readonly HistorySourceEvent[];readonly adoption?:{readonly count:number;readonly replay:HistoryReplay;readonly transition:HistoryTransition}}; +/** Resolve either a native interval or the owning adoption's proved virtual events, followed by later native events. */ +export function historyEventSource(snapshot:EngineSnapshot,pending:readonly OperationIntent[]):HistoryEventSource{ + const content=pending.filter(intent=>intent.changes.length).sort((a,b)=>a.sequence-b.sequence),first=content[0];if(!first)return{events:[]}; + const records=snapshot.runtime.ledger.operations,audits=[...snapshot.runtime.recoveries.values()],owners=audits.filter(audit=>audit.decisions.some(decision=>decision.replacementId===first.id)); + if(owners.length>1)throw fault('recovery_history_adoption_ambiguous',[first.id]); + const interpreter=(record:OperationRecord)=>{if(record.intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[record.intent.id]);}; + const pendingIds=new Set(content.map(intent=>intent.id)); + const order=(events:readonly HistorySourceEvent[])=>{const seen=new Set(),active:string[]=[];for(const event of events)if(event.record){const id=event.record.intent.id;if(seen.has(id))throw fault('recovery_history_event_order',[id]);seen.add(id);if(pendingIds.has(id))active.push(id);}if(active.length!==content.length||active.some((id,index)=>id!==content[index].id))throw fault('recovery_history_event_order');}; + const native=(record:OperationRecord):HistorySourceEvent=>{interpreter(record);const transition=record.intent.historyTransition;if(!transition)throw fault('recovery_history_transition_missing',[record.intent.id]);validateHistoryTransition(transition);return{record,transition};}; + if(!owners.length){ + const events=[...records.values()].filter(record=>record.intent.sequence>=first.sequence&&record.intent.changes.length).sort((a,b)=>a.intent.sequence-b.intent.sequence).map(native); + const start=events[0]?.transition.rootRevision??Infinity;if(audits.some(audit=>(audit.historyTransition?.rootRevision??audit.baseRootRevision+1)>=start))throw fault('recovery_history_adoption_replay_required'); + for(let i=1;iother.baseRootRevision+1>revision))throw fault('recovery_history_adoption_stale',[audit.id]); + const witnessed=new Set(),events:HistorySourceEvent[]=replay.events.map(event=>{ + if(event.kind==='adjustment')return{record:undefined,transition:event.transition};const record=records.get(event.operationId);if(!record?.intent.changes.length)throw fault('history_replay_operation_missing',[event.operationId]);interpreter(record);witnessed.add(event.operationId);return{record,transition:event.transition}; + }); + for(const intent of content)if(!witnessed.has(intent.id)&&!(intent.historyTransition&&intent.historyTransition.rootRevision>revision))throw fault('recovery_history_transition_missing',[intent.id]); + const later=[...records.values()].filter(record=>record.intent.changes.length&&record.intent.historyTransition&&record.intent.historyTransition.rootRevision>revision).sort((a,b)=>a.intent.sequence-b.intent.sequence).map(native); + let previous=revision;for(const event of later){if(event.transition.rootRevision<=previous)throw fault('recovery_history_event_order');previous=event.transition.rootRevision;} + order([...events,...later]);return{events:[...events,...later],adoption:{count:events.length,replay,transition:audit.historyTransition}}; +} diff --git a/src/lib/canvas-runtime/history-journal-recovery.ts b/src/lib/canvas-runtime/history-journal-recovery.ts index 17bf90b..4239003 100644 --- a/src/lib/canvas-runtime/history-journal-recovery.ts +++ b/src/lib/canvas-runtime/history-journal-recovery.ts @@ -1,29 +1,28 @@ import type { Change, Document, FieldChange, HistoryEntry, HistoryState, Id, OperationIntent, Registry } from '../canvas-core/types.ts'; -import { applyHistoryTransition, describeHistoryTransition, validateHistoryTransition } from '../canvas-core/history-transition.ts'; +import { applyHistoryTransition, describeHistoryTransition } 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 { HistoryReplay, HistoryReplayEvent } from './history-replay.ts'; +import { sameHistory, type HistoryReplay, type HistoryReplayEvent } from './history-replay.ts'; +import { historyEventSource } from './history-event-source.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;iintent.id)),source=historyEventSource(snapshot,pending); + const events=source.events.map(event=>({...event,before:undefined as HistoryState|undefined,after:undefined as HistoryState|undefined})); + let history=snapshot.history,adopted:HistoryState|undefined; + try{ + for(let index=events.length-1;index>=0;index--){const event=events[index];if(source.adoption&&index===source.adoption.count-1)adopted=history;event.after=history;history=applyHistoryTransition(history,event.transition,'reverse');event.before=history;} + if(source.adoption){if(!adopted)throw fault('recovery_history_transition_conflict');const before=applyHistoryTransition(adopted,source.adoption.transition,'reverse'),expected=applyHistoryTransition(before,source.adoption.replay.anchor,'forward');if(!sameHistory(history,expected))throw fault('recovery_history_transition_conflict');} + }catch{throw fault('recovery_history_transition_conflict');} const baseline=history,replayEvents:HistoryReplayEvent[]=[],adoptionRevision=snapshot.rootRevision+1; function recordStep(before:HistoryState,entryId:Id|undefined,operationId?:Id){const transition=describeHistoryTransition(before,history,adoptionRevision,entryId,!!operationId);if(transition)replayEvents.push(operationId?freeze({kind:'operation' as const,operationId,transition}):freeze({kind:'adjustment' as const,transition}));} let next=0;const touched=new Set(),retired=new Set();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;}; + 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 reviewPrefix(entry:HistoryEntry,document:Document):readonly Change[]{ const prefix=entry.changes; if(prefix.every(change=>change.kind==='field'&&!['ownerCanvasId','source','target'].includes(change.field))){ @@ -42,15 +41,30 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea 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 entries=new Map(history.entries),undo=[...history.undo];for(const removed of history.redo){entries.delete(removed);retired.add(removed);} const prior=entries.get(id),originalAppend=event.before!.entries.has(id),appending=!!prior; if(originalAppend&&(!prior&&!retired.has(id)||prior&&undo.at(-1)!==id)||!originalAppend&&prior)throw fault('recovery_history_traversal_order',[id]); const prefix=pending&&prior&&!touched.has(id)&&document?reviewPrefix(prior,document):prior?.changes??[]; 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);retired.delete(id);if(pending)touched.add(id); + entries.set(id,freeze({...metadata,changes:[...prefix,...changes],operationIds:[...(prior?.operationIds??[]),operationId]}));if(!appending)undo.push(id);while(undo.length>limit){const removed=undo.shift()!;entries.delete(removed);retired.add(removed);}history=state(undo,[],entries);retired.delete(id);if(pending)touched.add(id); + } + function applyAdjustment(event:typeof events[number]){ + const before=event.before!,after=event.after!,removed=new Set([...before.entries.keys()].filter(id=>!after.entries.has(id))); + if(!equal(after.undo,before.undo.filter(id=>!removed.has(id)))||!equal(after.redo,before.redo.filter(id=>!removed.has(id))))throw fault('recovery_history_adjustment_conflict'); + const entries=new Map(history.entries); + for(const patch of event.transition.entries){const current=entries.get(patch.id),original=before.entries.get(patch.id),desired=after.entries.get(patch.id);if(equal(current,desired))continue;if(!original||!equal(current,original))throw fault('recovery_history_adjustment_conflict',[patch.id]);if(desired)entries.set(patch.id,desired);else{entries.delete(patch.id);retired.add(patch.id);}} + history=state(history.undo.filter(id=>entries.has(id)),history.redo.filter(id=>entries.has(id)),entries); } function advance(intent?:OperationIntent){ - while(next>(); @@ -71,10 +85,16 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea 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)!); + if(!id)throw fault('recovery_history_traversal_order');const entry=history.entries.get(id); + if(!keep){ + if(!entry){if(!retired.has(id))throw fault('recovery_history_traversal_order',[id]);return{document,changes:[] as readonly Change[]};} + const direction=history.undo.at(-1)===id?'undo':history.redo.at(-1)===id?'redo':undefined; + if(direction)try{planHistoryChanges(document,direction==='undo'?invert(entry.changes):entry.changes,registry);}catch{throw fault('recovery_history_traversal_conflict',[id]);} + return{document,changes:[] as readonly Change[]}; + } + if(!entry||history[intent.origin==='undo'?'undo':'redo'].at(-1)!==id)throw fault('recovery_history_traversal_order',[id]); + if(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]);}} + remap=incarnationRemap(event.before!.entries.get(id)!,event.after!.entries.get(id)!); } return keep?rebaseIntent(document,intent,context,registry):{document,changes:[] as readonly Change[]}; }, diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index c1b0011..9d5d3a8 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -6,6 +6,26 @@ 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 adoptionConsumerCase(name,assert){ + for(const structural of [false,true]){ + 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}],[node('a')]),database=`${name}-adoption-consumer-${structural}`,storage=createBrowserStorage({name:database}),calls=[],saved=new Map();let document=original,versions=new Map([[root,1]]),reject=true,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),'adoption consumer lost a 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(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})},selected=()=>new Map([...engine.getSnapshot().runtime.ledger.operations].filter(([,record])=>record.phase!=='resolved').map(([id])=>[id,'keep-local'])); + const reopen=async()=>{for(const[id,request]of engine.getSnapshot().runtime.ledger.requests){if(saved.has(id))assert(saved.get(id)===request.body,'an earlier recovery request was rewritten');else saved.set(id,request.body);}await engine.close();engine=await openProjectEngine(options);}; + try{ + engine=await openProjectEngine(options); + if(structural){engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('frame'),componentId:'frame'}});tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'frame'}}});tx.dispatch({type:'element.create',element:node('member',child)});});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'member',incarnation:1},patch:{title:'discard member edit'}});} + else{engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'one'}},{groupId:'chain'});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'two'}},{groupId:'chain'});} + await engine.settle();let session=await engine.prepareRecovery(),first=session.plan(selected());await session.resolve(first);await engine.settle();await reopen();document=applyChanges(document,[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'remote 1',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1); + session=await engine.prepareRecovery();const decisions=selected();decisions.set(first.operationIds[structural?1:0],'discard-local');if(structural)reject=false;const second=session.plan(decisions);await session.resolve(second);await engine.settle();await reopen(); + if(!structural){engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'after adoption'}},{groupId:'chain'});await engine.settle();document=applyChanges(document,[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'remote 1',after:'remote 2',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1);session=await engine.prepareRecovery();const third=session.plan(selected());reject=false;await session.resolve(third);await engine.settle();await reopen();assert(engine.getSnapshot().runtime.recoveries.size===3,'nested adoption audit lineage was lost');assert(document.elements.get('a').data.title==='two'&&document.elements.get('a').data.text==='after adoption','post-adoption native edit was lost');assert(engine.editor.history.undo().status==='applied','nested field recovery undo failed');await engine.settle();assert(document.elements.get('a').data.title==='remote 2'&&document.elements.get('a').data.text==='','undo did not restore newest reviewed server baseline');assert(engine.editor.history.redo().status==='applied','nested field recovery redo failed');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===7,'nested field recovery repeated a request');} + else{assert(engine.getSnapshot().runtime.recoveries.size===2&&document.elements.get('member').data.title==='member','structural choice did not survive adoption');assert(engine.editor.history.undo().status==='applied','adopted structure undo failed');await engine.settle();assert(!document.canvases.has(child),'adopted structure undo left a child canvas');assert(engine.editor.history.redo().status==='applied','adopted structure redo failed');await engine.settle();assert(document.elements.get('member').incarnation===2&&document.elements.get('a').data.title==='remote 1','structural redo reused a life or erased remote data');assert(calls.filter(call=>call.method==='POST').length===5,'structural adoption repeated a request');} + await reopen();for(const[id,body]of saved)assert(engine.getSnapshot().runtime.ledger.requests.get(id).body===body,'original frozen request bytes changed after final reopen'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} + } +} + async function adoptionEvidenceCase(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:''}},original=createDocument(root,[{id:root,parent:null}],[node]),database=`${name}-adoption-evidence`,storage=createBrowserStorage({name:database}),calls=[];let document=original,version=1,reject=true,engine; const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:Object.fromEntries(document.tombstones),versions:{[root]:version}}); @@ -423,6 +443,8 @@ export async function run() { results.push('later confirmed undo/redo restorations replay history only, preserve request bytes through reopening and remap older and remaining history lifecycles'); await adoptionEvidenceCase(name,assert); results.push('adoption anchor and event evidence survive IndexedDB reopening and compose mixed geometry/content choices exactly without extra wire writes'); + await adoptionConsumerCase(name,assert); + results.push('adoption evidence is consumed across repeated recovery/reopening, preserving old requests, latest reviewed field baselines and structural lifecycles'); 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 fd3d13a..7b6d3a4 100644 --- a/tests/headless/structure-recovery.test.mjs +++ b/tests/headless/structure-recovery.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; 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 { createDocument,createRegistry,builtinComponents,applyChanges,validateDocument,createEditor,canvasTransform,toWorld,convertGeometry,defineComponent,movable,resizable,rotatable,unitScale,withUnitScale,describeHistoryTransition } 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 { replayHistoryEvidence,sameHistory } from '../../src/lib/canvas-runtime/history-replay.ts'; @@ -396,3 +396,54 @@ test('a later confirmed redo restoration remaps a remaining redo entry into its test('settled history adjustments in an adoption proof preserve the explicit removal of a remote-overwritten 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:'typing'});await engine.settle();f.reject=true;const edit=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{groupId:'typing'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote',beforePresent:true,afterPresent:true}]);const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([[edit.operationIds[0],'discard-local']]));assert.equal(plan.history.entries.size,0);assert.ok(plan.audit.historyReplay.events.some(event=>event.kind==='adjustment'));assert.ok(sameHistory(replayHistoryEvidence(before.history,plan.audit.historyReplay),plan.history));await session.resolve(plan);assert.equal(decodeRecovery(f.image(),registry).history.entries.size,0);await engine.close(); }); + + +test('repeated adoption re-plans replacement groups plus a later native edit from the newest reviewed baseline',async()=>{ + const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'one'}},{groupId:'chain'});engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'two'}},{groupId:'chain'});await engine.settle();const bodies=new Map(); + for(let round=0;round<3;round++){ + if(round)f.remote([{kind:'field',target:target('a'),field:'data.title',before:round===1?'a':'remote 1',after:'remote '+round,beforePresent:true,afterPresent:true}]); + if(round===2){engine.editor.dispatch({type:'element.data',target:target('a'),patch:{text:'native after adoption'}},{groupId:'chain'});await engine.settle();} + const session=await engine.prepareRecovery(),selected=choices(engine);if(round===1)selected.set([...selected.keys()][0],'discard-local');const plan=session.plan(selected);if(round===2)f.reject=false;await session.resolve(plan);await engine.settle();for(const[id,request]of engine.getSnapshot().runtime.ledger.requests){if(bodies.has(id))assert.equal(request.body,bodies.get(id));else bodies.set(id,request.body);}assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,round+1); + } + assert.equal(f.server().elements.get('a').data.title,'two');assert.equal(f.server().elements.get('a').data.text,'native after adoption');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('a').data.title,'remote 2');assert.equal(f.server().elements.get('a').data.text,'');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'two');await engine.close(); +}); + +test('a structural replacement and later native member edit recover across adoption with lifecycle-safe undo/redo',async()=>{ + const f=fixture(),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});});await engine.settle();let session=await engine.prepareRecovery();await session.resolve(session.plan(choices(engine)));await engine.settle();engine.editor.dispatch({type:'element.data',target:target('member'),patch:{title:'edited after adoption'}});await engine.settle();f.remote([{kind:'field',target:target('b'),field:'data.title',before:'b',after:'remote b',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('member').data.title,'edited after adoption');for(let i=0;i<2;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}assert.equal(f.server().canvases.has('inside'),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('member').incarnation,2);assert.equal(f.server().elements.get('member').data.title,'edited after adoption');assert.equal(f.server().elements.get('b').data.title,'remote b');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,2);await engine.close(); +}); + +test('undo-redo replacements recover across an adoption without resurrecting a stale remote 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();let session=await engine.prepareRecovery();await session.resolve(session.plan(choices(engine)));await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'reviewed remote',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'reviewed 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,'reviewed remote');await engine.close(); +}); + +test('an applied replacement prefix is reconstructed from the adoption once while its sibling is replanned',async()=>{ + const f=fixture(),submit=f.port.submit;let mode='all';f.port.submit=async request=>{const intent=JSON.parse(request.body).intent;f.reject=mode==='all'||mode==='child'&&intent.changes.some(change=>change.kind==='field'&&change.target.id==='c');return submit(request);};const engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}},{groupId:'shared'});engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'second'}},{groupId:'shared'});await engine.settle();let session=await engine.prepareRecovery(),firstPlan=session.plan(choices(engine));mode='child';await session.resolve(firstPlan);await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.operations.get(firstPlan.operationIds[0]).resolution,'applied');f.remote([{kind:'field',target:target('c'),field:'data.title',before:'c',after:'remote c',beforePresent:true,afterPresent:true}]);session=await engine.prepareRecovery();const secondPlan=session.plan(choices(engine));mode='none';await session.resolve(secondPlan);await engine.settle();assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===firstPlan.operationIds[0]).length,1);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('c').data.title,'remote c');await engine.close(); +}); + +test('settled pruning is replayed safely when earlier replacement creation and its undo are both discarded',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'ack'}},{groupId:'a'});await engine.settle();f.reject=true;engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'b pending'}});engine.editor.history.undo();const suffix=engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'a pending'}},{groupId:'a'});await engine.settle();f.remote([{kind:'field',target:target('a'),field:'data.title',before:'ack',after:'remote a',beforePresent:true,afterPresent:true}]);let session=await engine.prepareRecovery(),selected=choices(engine);selected.set(suffix.operationIds[0],'discard-local');const first=session.plan(selected);assert.ok(first.audit.historyReplay.events.some(event=>event.kind==='adjustment'));await session.resolve(first);await engine.settle();const count=f.calls.length;session=await engine.prepareRecovery();const plan=session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local'])));await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,count);assert.equal(engine.getSnapshot().history.entries.size,0);assert.equal(f.server().elements.get('a').data.title,'remote a');assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,2);await engine.close(); +}); + +test('discarding both an edit and its undo, or both a rejected undo and redo, does not invent a stack move',async()=>{ + for(const confirmed of [false,true]){ + const f=fixture(),engine=await f.open();if(confirmed)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();if(confirmed)engine.editor.history.redo();await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local'])));await session.resolve(plan);assert.equal(engine.getSnapshot().history.redo.length,0);assert.equal(engine.getSnapshot().history.undo.length,confirmed?1:0);assert.equal(f.server().elements.get('a').data.title,confirmed?'edited':'a');await engine.close(); + } +}); + + +test('adoption replay refuses missing, future, incomplete or divergent evidence without changing live state',async()=>{ + const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}});await engine.settle();const first=await engine.prepareRecovery(),plan=first.plan(choices(engine));await first.resolve(plan);await engine.settle();const native=engine.editor.history.undo();await engine.settle();const original=engine.getSnapshot(),canonical=await f.port.read(),selected=choices(engine); + for(const mode of ['missing','future','macro-future','anchor','native-missing']){ + const audit=structuredClone(original.runtime.recoveries.get(plan.id)),recoveries=new Map(original.runtime.recoveries),operations=new Map(original.operations),records=new Map(original.runtime.ledger.operations); + if(mode==='missing')delete audit.historyReplay;else if(mode==='future')audit.historyReplay={version:2,opaque:'keep'};else if(mode==='macro-future')audit.historyTransition={version:2,opaque:'keep'};else if(mode==='anchor')audit.historyReplay.anchor.before.undo.push('unrelated'); + else{const id=native.operationIds[0],intent={...records.get(id).intent};delete intent.historyTransition;records.set(id,{...records.get(id),intent});const core={...operations.get(id)};delete core.historyTransition;operations.set(id,core);} + recoveries.set(plan.id,audit);const snapshot={...original,operations,runtime:{...original.runtime,recoveries,ledger:{...original.runtime.ledger,operations:records}}};assert.throws(()=>planConflictRecovery(snapshot,canonical,selected,registry,()=>crypto.randomUUID()),mode==='future'?/history_replay_interpreter_unavailable/:mode==='macro-future'?/history_transition_interpreter_unavailable/:/recovery_history/);assert.equal(engine.getSnapshot(),original); + } + await engine.close(); +}); + + +test('an adoption operation cannot also masquerade as a later native no-op event',async()=>{ + const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'pending'}},{history:false});await engine.settle();const first=await engine.prepareRecovery(),plan=first.plan(choices(engine));await first.resolve(plan);await engine.settle();const source=engine.getSnapshot(),id=plan.operationIds[0],transition=describeHistoryTransition(source.history,source.history,source.rootRevision,undefined,true),operations=new Map(source.operations),records=new Map(source.runtime.ledger.operations),intent={...records.get(id).intent,historyTransition:transition};operations.set(id,{...operations.get(id),historyTransition:transition});records.set(id,{...records.get(id),intent});const forged={...source,operations,runtime:{...source.runtime,ledger:{...source.runtime.ledger,operations:records}}},awaitedCanonical=await f.port.read();assert.throws(()=>planConflictRecovery(forged,awaitedCanonical,choices(engine),registry,()=>crypto.randomUUID()),/recovery_history_event_order/); + await engine.close(); +});