From 564e8788444041b336b9492c034d86303c20531d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 03:53:53 +0800 Subject: [PATCH] Persist verifiable adoption history anchors and replay evidence --- src/lib/canvas-core/document.ts | 5 +++- src/lib/canvas-runtime/change-rebase.ts | 2 +- src/lib/canvas-runtime/conflicts.ts | 11 +++++--- .../history-journal-recovery.ts | 12 ++++++--- src/lib/canvas-runtime/history-replay.ts | 20 ++++++++++++++ src/lib/canvas-runtime/recovery.ts | 27 ++++++++++++++++--- tests/headless/browser-storage-cases.mjs | 14 ++++++++++ tests/headless/history-transition.test.mjs | 25 +++++++++++++++++ tests/headless/structure-recovery.test.mjs | 6 +++++ 9 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 src/lib/canvas-runtime/history-replay.ts diff --git a/src/lib/canvas-core/document.ts b/src/lib/canvas-core/document.ts index 98f3a4f..2ebb46d 100644 --- a/src/lib/canvas-core/document.ts +++ b/src/lib/canvas-core/document.ts @@ -104,5 +104,8 @@ export function applyChanges(document: Document, changes: readonly Change[], con return Object.freeze({ projectId: document.projectId, elements: readMap(elements), canvases: readMap(canvases), tombstones: readMap(tombstones) }); } export function invert(changes: readonly Change[]): readonly Change[] { - return changes.slice().reverse().map(change => freeze({ ...change, before: change.after, after: change.before, ...(change.kind === 'field' ? { beforePresent: change.afterPresent, afterPresent: change.beforePresent } : {}) }) as Change); + return changes.slice().reverse().map(change => { + if(change.kind==='field'){const{beforePresent,afterPresent,...record}=change;return freeze({...record,before:change.after,after:change.before,...(afterPresent===undefined?{}:{beforePresent:afterPresent}),...(beforePresent===undefined?{}:{afterPresent:beforePresent})});} + return freeze({...change,before:change.after,after:change.before}) as Change; + }); } diff --git a/src/lib/canvas-runtime/change-rebase.ts b/src/lib/canvas-runtime/change-rebase.ts index 096d37a..35fd865 100644 --- a/src/lib/canvas-runtime/change-rebase.ts +++ b/src/lib/canvas-runtime/change-rebase.ts @@ -162,7 +162,7 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In desired={...current.geometry,...Object.fromEntries((['x','y','width','height','rotation'] as const).filter(key=>start[key]!==end[key]).map(key=>[key,end[key]]))}; const next=desired as unknown as typeof current.geometry;for(const [keys,capability]of [[['x','y'],'geometry.move'],[['width','height'],'geometry.resize'],[['rotation'],'geometry.rotate']] as const)if(keys.some(key=>current.geometry[key]!==next[key])&&!registry.supports(current,capability))throw fault('unsupported_capability',[current.id]); } - if(!equal(actual,desired)||(present??true)!==(original.afterPresent??true))apply({...original,before:actual,beforePresent:present,after:desired}); + if(!equal(actual,desired)||(present??true)!==(original.afterPresent??true)){const{beforePresent:_previous,...record}=original;apply({...record,before:actual,...(present===undefined?{}:{beforePresent:present}),after:desired});} } for(const original of deferredPaths){ const edge=target(original),intended=context.after.get(edge.id);if(edge.structure!=='connection'||intended?.structure!=='connection')throw fault('invalid_endpoint',[edge.id]); diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts index 33e3907..92c4cd1 100644 --- a/src/lib/canvas-runtime/conflicts.ts +++ b/src/lib/canvas-runtime/conflicts.ts @@ -1,11 +1,12 @@ import type { Change, Document, FieldChange, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; -import { describeHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts'; +import { describeHistoryTransition, historyTransitionChanges, type HistoryTransition } 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 type { CanonicalProposal, EngineSnapshot } from './contracts.ts'; import { createLedger, type Presence } from './ledger.ts'; import { intentContexts, rebaseIntent } from './change-rebase.ts'; +import type { HistoryReplay } from './history-replay.ts'; import { createHistoryJournalRecovery } from './history-journal-recovery.ts'; import { replanTraversals } from './traversal-recovery.ts'; import { operationCanvases, validateCanonicalRead } from './reconcile.ts'; @@ -13,7 +14,7 @@ import { referencedResources } from './resources.ts'; export type RecoveryChoice = 'keep-local' | 'discard-local'; export type RecoveryDecision = { readonly operationId: Id; readonly choice: RecoveryChoice; readonly replacementId?: Id; readonly resolution: 'superseded' | 'discarded' | 'satisfied' }; -export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json; readonly historyTransition?:HistoryTransition }; +export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json; readonly historyTransition?:HistoryTransition; readonly historyReplay?:HistoryReplay }; export type ConflictPlan = { readonly id: Id; readonly baseRootRevision: number; readonly document: Document; readonly history: HistoryState; readonly operations: EngineSnapshot['operations']; readonly ledger: EngineSnapshot['runtime']['ledger']; readonly audit: RecoveryAudit; readonly resourceIds: readonly Id[]; readonly operationIds: readonly Id[] }; const fieldKey = (change: FieldChange) => JSON.stringify([change.target.id,change.target.incarnation,change.field]); function value(document: Document, change: FieldChange, allowMissing=false): {before:Json;beforePresent?:boolean;missing?:boolean} { @@ -79,13 +80,15 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni 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'; ledger.add(replacement,presence); } + const historyReplay=journal?.evidence(); // Conservative pins include the archived document/history and unresolved opaque local resources. const resourceIds=new Set(snapshot.runtime.resources.keys()); for(const record of records)for(const resource of referencedResources(record.intent.changes,registry))resourceIds.add(resource); for(const replacement of replacements.values())if(replacement)for(const resource of referencedResources(replacement.changes,registry))resourceIds.add(resource); + if(historyReplay)for(const transition of [historyReplay.anchor,...historyReplay.events.map(event=>event.transition)])for(const resource of referencedResources(historyTransitionChanges(transition),registry))resourceIds.add(resource); const backup=JSON.parse(JSON.stringify({document:documentValue(snapshot.document),acknowledged:documentValue(snapshot.runtime.acknowledged),versions:[...snapshot.runtime.versions],resourceIds:[...resourceIds],history:{undo:snapshot.history.undo,redo:snapshot.history.redo,entries:[...snapshot.history.entries]}})) as Json; - const historyTransition=describeHistoryTransition(snapshot.history,history,snapshot.rootRevision+1); - const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup,...(historyTransition?{historyTransition}:{})}); + const historyTransition=describeHistoryTransition(snapshot.history,history,snapshot.rootRevision+1,undefined,!!historyReplay); + const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup,...(historyTransition?{historyTransition}:{}),...(historyReplay?{historyReplay}:{})}); 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]:[]))}); } diff --git a/src/lib/canvas-runtime/history-journal-recovery.ts b/src/lib/canvas-runtime/history-journal-recovery.ts index 728dbff..17bf90b 100644 --- a/src/lib/canvas-runtime/history-journal-recovery.ts +++ b/src/lib/canvas-runtime/history-journal-recovery.ts @@ -1,9 +1,10 @@ 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 { applyHistoryTransition, describeHistoryTransition, 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 { HistoryReplay, HistoryReplayEvent } from './history-replay.ts'; import type { EngineSnapshot } from './contracts.ts'; /** Native history events are reconstructed from evidence; all cursors remain private until the recovery root is accepted. */ @@ -18,6 +19,8 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea let history=snapshot.history; try{for(const event of [...events].reverse()){event.after=history;history=applyHistoryTransition(history,event.transition,'reverse');event.before=history;}} 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;}; @@ -47,7 +50,7 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea 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); } function advance(intent?:OperationIntent){ - while(next>(); @@ -76,15 +79,16 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea 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.changes.length)return;const previous=history,event=eventFor(intent),id=event.transition.entryId; if(intent.origin==='user'){if(replacement)addUser(intent,replacement.changes,replacement.id,true,before);else skipUser(intent,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);retired.add(id);} history=state(undo,redo,entries);touched.add(id); } - next++; + recordStep(previous,id,replacement?.id);next++; }, finish(){advance();return history;}, + evidence():HistoryReplay{return freeze({version:1 as const,anchor:describeHistoryTransition(snapshot.history,baseline,adoptionRevision,undefined,true)!,events:replayEvents});}, }; } diff --git a/src/lib/canvas-runtime/history-replay.ts b/src/lib/canvas-runtime/history-replay.ts new file mode 100644 index 0000000..ffc9930 --- /dev/null +++ b/src/lib/canvas-runtime/history-replay.ts @@ -0,0 +1,20 @@ +import type { HistoryState, Id } from '../canvas-core/types.ts'; +import { applyHistoryTransition, validateHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts'; +import { fault, equal, validJson } from '../canvas-core/values.ts'; + +export type HistoryReplayEvent={readonly kind:'operation';readonly operationId:Id;readonly transition:HistoryTransition}|{readonly kind:'adjustment';readonly transition:HistoryTransition}; +export type HistoryReplay={readonly version:1;readonly anchor:HistoryTransition;readonly events:readonly HistoryReplayEvent[]}; +/** Local evidence only. The anchor and event deltas explain one atomic adoption, not new service writes. */ +export function validateHistoryReplay(value:unknown,revision:number,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryReplay{ + if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const raw=value as Record; + if(raw.version!==1)throw fault('history_replay_interpreter_unavailable');if(!validJson(value))throw fault('invalid_history_replay');validateHistoryTransition(raw.anchor,checkChanges);if(raw.anchor.rootRevision!==revision||raw.anchor.entryId!==undefined||!Array.isArray(raw.events))throw fault('invalid_history_replay');const ids=new Set(); + for(const value of raw.events){if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const event=value as Record;validateHistoryTransition(event.transition,checkChanges);if(event.transition.rootRevision!==revision)throw fault('invalid_history_replay');if(event.kind==='operation'){if(typeof event.operationId!=='string'||!event.operationId||ids.has(event.operationId))throw fault('invalid_history_replay');ids.add(event.operationId);}else if(event.kind!=='adjustment'||event.operationId!==undefined)throw fault('invalid_history_replay');} +} +export function replayHistoryEvidence(before:HistoryState,replay:HistoryReplay,hasOperation?:(id:Id)=>boolean):HistoryState{ + validateHistoryReplay(replay,replay.anchor.rootRevision);let history=applyHistoryTransition(before,replay.anchor,'forward'); + for(const event of replay.events){if(event.kind==='operation'&&hasOperation&&!hasOperation(event.operationId))throw fault('history_replay_operation_missing',[event.operationId]);history=applyHistoryTransition(history,event.transition,'forward');if(event.kind==='operation'&&event.transition.entryId&&!history.entries.get(event.transition.entryId)?.operationIds.includes(event.operationId))throw fault('history_replay_operation_mismatch',[event.operationId]);} + return history; +} +export function sameHistory(left:HistoryState,right:HistoryState):boolean{ + const value=(history:HistoryState)=>({undo:history.undo,redo:history.redo,entries:[...history.entries].sort(([a],[b])=>a.localeCompare(b))});return equal(value(left),value(right)); +} diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index ec1be82..f501a45 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -1,6 +1,7 @@ import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; +import { validateHistoryReplay, replayHistoryEvidence, sameHistory } from './history-replay.ts'; import type { RecoveryAudit } from './conflicts.ts'; -import { validateHistoryTransition } from '../canvas-core/history-transition.ts'; +import { applyHistoryTransition, validateHistoryTransition, type HistoryTransition } from '../canvas-core/history-transition.ts'; import { createDocument, validateDocument } from '../canvas-core/document.ts'; import { fault, freeze, readMap, validJson, equal } from '../canvas-core/values.ts'; import type { EngineSnapshot, RuntimeState } from './contracts.ts'; @@ -166,12 +167,32 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn const working=readDocument(backup.document,registry),base=readDocument(backup.acknowledged,registry),versions=table(backup.versions,value=>integer(value,1)); if(working.projectId!==document.projectId||base.projectId!==document.projectId||[...base.canvases.keys()].some(id=>!versions.has(id)))throw fault('corrupt_recovery_image'); readHistory(object(backup.history),operations);if(!equal(strings(backup.resourceIds),resourcePins.get(`recovery:${id}`)))throw fault('corrupt_recovery_image'); - const ids=new Set();for(const value of array(raw.decisions)){const decision=object(value),operationId=string(decision.operationId),record=records.get(operationId),request=record?.requestId?requests.get(record.requestId):undefined; + const localReplacements:Id[]=[],ids=new Set();for(const value of array(raw.decisions)){const decision=object(value),operationId=string(decision.operationId),record=records.get(operationId),request=record?.requestId?requests.get(record.requestId):undefined; if(ids.has(operationId)||!record||record.phase!=='resolved'||record.recoveryId!==id||record.resolution!==decision.resolution||record.replacementId!==decision.replacementId||record.intent.operationType==='asset.upload'||record.requestId&&(!request||request.phase!=='resolved'||request.outcome!=='not-applied'))throw fault('corrupt_recovery_image');ids.add(operationId); if(!['keep-local','discard-local'].includes(string(decision.choice))||(decision.choice==='discard-local')!==(decision.resolution==='discarded'))throw fault('corrupt_recovery_image'); - if(decision.resolution==='superseded'){const replacementId=string(decision.replacementId),replacement=records.get(replacementId);if(replacementOwners.has(replacementId))throw fault('corrupt_recovery_image');replacementOwners.add(replacementId);if(!replacement||replacement.intent.sequence<=record.intent.sequence||replacement.intent.operationType!==record.intent.operationType)throw fault('corrupt_recovery_image');} + if(decision.resolution==='superseded'){const replacementId=string(decision.replacementId),replacement=records.get(replacementId);if(replacementOwners.has(replacementId))throw fault('corrupt_recovery_image');replacementOwners.add(replacementId);localReplacements.push(replacementId);if(!replacement||replacement.intent.sequence<=record.intent.sequence||replacement.intent.operationType!==record.intent.operationType)throw fault('corrupt_recovery_image');} else if(!['discarded','satisfied'].includes(string(decision.resolution))||decision.replacementId!==undefined)throw fault('corrupt_recovery_image'); } + if(raw.historyReplay!==undefined){ + const replay=object(raw.historyReplay);integer(replay.version,1); + if(replay.version===1){ + try{ + validateHistoryReplay(replay,(raw.baseRootRevision as number)+1,changes); + if(!raw.historyTransition)throw fault('corrupt_recovery_image'); + const local=new Set(localReplacements); + const expectedOrder=[...localReplacements].sort((a,b)=>records.get(a)!.intent.sequence-records.get(b)!.intent.sequence); + const actualOrder=replay.events.flatMap(event=>event.kind==='operation'&&local.has(event.operationId)?[event.operationId]:[]); + if(!equal(actualOrder,expectedOrder))throw fault('corrupt_recovery_image'); + const original=readHistory(object(backup.history),operations); + const composed=replayHistoryEvidence(original,replay,id=>{ + const record=records.get(id); + return!!record?.intent.changes.length&&['user','undo','redo'].includes(record.intent.origin)&&(local.has(id)||record.resolution==='applied'); + }); + const expected=applyHistoryTransition(original,raw.historyTransition as HistoryTransition,'forward'); + if(!sameHistory(composed,expected))throw fault('corrupt_recovery_image'); + }catch{throw fault('corrupt_recovery_image');} + } + } if(!resourcePins.has(`recovery:${id}`))throw fault('corrupt_recovery_image');return freeze(value as RecoveryAudit); }); for(const record of records.values()){ diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 9e92819..c1b0011 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -1,3 +1,4 @@ +import {replayHistoryEvidence,sameHistory} from '../../src/lib/canvas-runtime/history-replay.ts'; import { createBrowserStorage } from '../../src/lib/canvas-runtime/storage.ts'; import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts'; import { createDocument, createRegistry, builtinComponents, applyChanges, canvasTransform, applyHistoryTransition } from '../../src/lib/canvas-core/index.ts'; @@ -5,6 +6,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 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}}); + const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);assert(!request.body.includes('historyReplay'),'local history evidence leaked into wire payload');if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};assert(body.expected_versions[root]===version,'adoption evidence changed the version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);version++;return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)}};}; + 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.geometry',target:{id:'a',incarnation:1},patch:{width:250}},{groupId:'adoption'});await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()][0],discard=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'discard me'}},{groupId:'adoption'});engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'kept'}},{groupId:'adoption'});await engine.settle();const session=await engine.prepareRecovery(),selected=new Map(session.pendingOperationIds.map(id=>[id,id===discard.operationIds[0]?'discard-local':'keep-local'])),plan=session.plan(selected);reject=false;await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options); + const snapshot=engine.getSnapshot(),audit=snapshot.runtime.recoveries.get(plan.id),backup=audit.backup.history,before={undo:backup.undo,redo:backup.redo,entries:new Map(backup.entries)},reconstructed=replayHistoryEvidence(before,audit.historyReplay,id=>snapshot.operations.has(id));assert(sameHistory(reconstructed,snapshot.history),'persisted adoption witness did not compose to its history');assert(audit.historyReplay.events.filter(event=>event.kind==='operation').length===2,'discarded contribution became a replay operation');assert(snapshot.runtime.ledger.requests.get(old.id).body===old.body,'adoption evidence changed old request bytes');assert(document.elements.get('a').geometry.width===250&&document.elements.get('a').data.title==='kept','adoption content or geometry changed');assert(engine.editor.history.undo().status==='applied','geometry evidence broke undo');await engine.settle();assert(document.elements.get('a').geometry.width===200&&document.elements.get('a').data.title==='a','adopted group inverse is incorrect');assert(engine.editor.history.redo().status==='applied','geometry evidence broke redo');await engine.settle();assert(calls.filter(call=>call.method==='POST').length===5,'adoption evidence generated an extra content write'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} +} + async function confirmedTraversalCase(name,assert){ for(const direction of ['undo','redo']){ 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)]),database=`${name}-confirmed-${direction}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine; @@ -409,6 +421,8 @@ export async function run() { results.push('pure user group recovery preserves a later acknowledged contribution across keep/discard, IndexedDB reopening and atomic undo/redo without retransmission'); await confirmedTraversalCase(name,assert); 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'); return results; } finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); } } diff --git a/tests/headless/history-transition.test.mjs b/tests/headless/history-transition.test.mjs index 8e175ff..0f8cb85 100644 --- a/tests/headless/history-transition.test.mjs +++ b/tests/headless/history-transition.test.mjs @@ -4,6 +4,7 @@ import {createEditor,createDocument,createRegistry,builtinComponents,applyHistor import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts'; import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts'; import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts'; +import {replayHistoryEvidence,sameHistory,validateHistoryReplay} from '../../src/lib/canvas-runtime/history-replay.ts'; import {resourcePins} from '../../src/lib/canvas-runtime/resources.ts'; const 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('project',[{id:'root',parent:null}],[node]); const value=h=>({undo:h.undo,redo:h.redo,entries:[...h.entries].sort(([a],[b])=>a.localeCompare(b))}); @@ -72,3 +73,27 @@ test('restoration evidence reverses incarnation remapping across earlier history const editor=createEditor({registry,document});editor.dispatch({type:'element.create',element:{...node,id:'new'}});editor.dispatch({type:'element.data',target:{id:'new',incarnation:1},patch:{title:'edited'}});editor.dispatch({type:'element.remove',target:{id:'new',incarnation:1}});const before=editor.getSnapshot(),result=editor.history.undo(),after=editor.getSnapshot(),transition=after.operations.get(result.operationIds[0]).historyTransition; assert.equal(after.document.elements.get('new').incarnation,2);assert.ok(transition.entries.length>=3);assert.deepEqual(value(applyHistoryTransition(after.history,transition,'reverse')),value(before.history));assert.deepEqual(value(applyHistoryTransition(before.history,transition,'forward')),value(after.history)); }); + + +test('adoption evidence composes the anchor and replacement events into the exact planned history and survives decoding',async()=>{ + for(const geometry of [false,true]){ + const f=await fixture(),engine=f.engine;try{if(geometry)engine.editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});else edit(engine.editor,'first',{groupId:'typing'});await engine.settle();if(!geometry)edit(engine.editor,'second',{groupId:'typing'});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),selected=new Map([...before.runtime.ledger.operations].filter(([,record])=>record.phase!=='resolved').map(([id])=>[id,'keep-local'])),plan=session.plan(selected),proof=plan.audit.historyReplay;assert.ok(proof);validateHistoryReplay(proof,before.rootRevision+1);assert.ok(sameHistory(replayHistoryEvidence(before.history,proof,id=>plan.operations.has(id)),plan.history));assert.deepEqual(proof.events.filter(event=>event.kind==='operation').map(event=>event.operationId),plan.operationIds);await session.resolve(plan);await engine.settle();const image=f.image(),decoded=decodeRecovery(image,registry);assert.deepEqual(decoded.runtime.recoveries.get(plan.id).historyReplay,proof); + const bad=f.image();bad.payload.runtime.recoveries[0][1].historyReplay.events.pop();assert.throws(()=>decodeRecovery(bad,registry),/corrupt_recovery_image/); + const missing=f.image();missing.payload.runtime.recoveries[0][1].historyReplay.events[0].operationId='missing';assert.throws(()=>decodeRecovery(missing,registry),/corrupt_recovery_image/); + const future=f.image();future.payload.runtime.recoveries[0][1].historyReplay={version:2,opaque:{keep:true}};assert.deepEqual(decodeRecovery(future,registry).runtime.recoveries.get(plan.id).historyReplay,{version:2,opaque:{keep:true}}); + }finally{await engine.close();} + } +}); + +test('geometry undo and redo keep optional presence markers absent and history transitions serializable',()=>{ + const editor=createEditor({registry,document});editor.dispatch({type:'element.geometry',target:{id:'a',incarnation:1},patch:{width:250}});for(const direction of ['undo','redo']){const before=editor.getSnapshot(),result=editor.history[direction](),after=editor.getSnapshot(),intent=after.operations.get(result.operationIds[0]);assert.equal(Object.hasOwn(intent.changes[0],'beforePresent'),false);assert.equal(Object.hasOwn(intent.changes[0],'afterPresent'),false);assert.deepEqual(JSON.parse(JSON.stringify(intent.historyTransition)),intent.historyTransition);assert.ok(sameHistory(applyHistoryTransition(before.history,intent.historyTransition,'forward'),after.history));} +}); + + +test('history-disabled replacement evidence cannot be omitted merely because the final stacks are unchanged',async()=>{ + const f=await fixture(),engine=f.engine;try{edit(engine.editor,'untracked',{history:false});await engine.settle();const before=engine.getSnapshot(),session=await engine.prepareRecovery(),plan=session.plan(new Map([...before.runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.history.entries.length,0);assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,1);image.payload.runtime.recoveries[0][1].historyReplay.events=[];assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();} +}); + +test('adoption evidence preserves replacement sequence even when all history transitions are no-ops',async()=>{ + const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first',{history:false});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'second'}},{history:false});await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map([...engine.getSnapshot().runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,2);image.payload.runtime.recoveries[0][1].historyReplay.events.reverse();assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();} +}); diff --git a/tests/headless/structure-recovery.test.mjs b/tests/headless/structure-recovery.test.mjs index a0316e8..fd3d13a 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 { replayHistoryEvidence,sameHistory } from '../../src/lib/canvas-runtime/history-replay.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}; @@ -390,3 +391,8 @@ test('a later confirmed redo restoration remaps a remaining redo entry into its f.reject=true;const first=engine.editor.history.redo();await engine.settle();f.reject=false;const confirmed=engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('new').incarnation,2);const session=await engine.prepareRecovery(),plan=session.plan(new Map([[first.operationIds[0],'keep-local']]));await session.resolve(plan);await engine.settle();assert.equal(f.calls.filter(call=>JSON.parse(call.body).intent.id===confirmed.operationIds[0]).length,1);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('new').data.title,'edited new'); for(let i=0;i<3;i++){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();}for(let i=0;i<3;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 new');await engine.close(); }); + + +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(); +});