Persist verifiable adoption history anchors and replay evidence

This commit is contained in:
2026-11-08 03:53:53 +08:00
parent 1994ff2576
commit 564e878844
9 changed files with 109 additions and 13 deletions
+4 -1
View File
@@ -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;
});
}
+1 -1
View File
@@ -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]);
+7 -4
View File
@@ -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<Id,Presence>={};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]:[]))});
}
@@ -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<Id>(),retired=new Set<Id>();let remap:((change:Change)=>Change)|undefined;
const state=(undo:readonly Id[],redo:readonly Id[],entries:ReadonlyMap<Id,HistoryEntry>)=>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<events.length&&events[next].record.intent.id!==intent?.id){const event=events[next],record=event.record;if(pendingIds.has(record.intent.id))throw fault('recovery_history_event_order',[record.intent.id]);if(record.resolution!=='applied')throw fault('recovery_history_event_unresolved',[record.intent.id]);if(record.intent.origin==='user')addUser(record.intent,record.intent.changes,record.intent.id,false);else if(['undo','redo'].includes(record.intent.origin))addConfirmedTraversal(record.intent);else throw fault('recovery_history_event_origin_unsupported',[record.intent.id]);next++;}
while(next<events.length&&events[next].record.intent.id!==intent?.id){const event=events[next],record=event.record;if(pendingIds.has(record.intent.id))throw fault('recovery_history_event_order',[record.intent.id]);if(record.resolution!=='applied')throw fault('recovery_history_event_unresolved',[record.intent.id]);const before=history;if(record.intent.origin==='user')addUser(record.intent,record.intent.changes,record.intent.id,false);else if(['undo','redo'].includes(record.intent.origin))addConfirmedTraversal(record.intent);else throw fault('recovery_history_event_origin_unsupported',[record.intent.id]);recordStep(before,event.transition.entryId,record.intent.id);next++;}
}
function incarnationRemap(before:HistoryEntry,after:HistoryEntry){
const maps=new Map<Id,Map<number,number>>();
@@ -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});},
};
}
+20
View File
@@ -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<string,unknown>;
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<Id>();
for(const value of raw.events){if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_history_replay');const event=value as Record<string,unknown>;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));
}
+24 -3
View File
@@ -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<Id>();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<Id>();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()){
+14
View File
@@ -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); }
}
@@ -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();}
});
@@ -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();
});