Replay confirmed history traversals without repeating applied content

This commit is contained in:
2026-11-08 03:41:51 +08:00
parent d99b6951cf
commit 1994ff2576
3 changed files with 55 additions and 1 deletions
@@ -47,7 +47,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'||record.intent.origin!=='user')throw fault('recovery_history_confirmed_traversal_replay_required',[record.intent.id]);addUser(record.intent,record.intent.changes,record.intent.id,false);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]);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++;}
}
function incarnationRemap(before:HistoryEntry,after:HistoryEntry){
const maps=new Map<Id,Map<number,number>>();
@@ -55,6 +55,15 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
before.changes.forEach((a,index)=>{const b=after.changes[index];if(a.kind==='field'&&b?.kind==='field')pair(a.target,b.target);else if(a.kind==='element'&&b?.kind==='element'){pair(a.before,b.before);pair(a.after,b.after);}});
return(change:Change):Change=>{const record=<T extends {id:Id;incarnation:number}>(value:T|null)=>value?{...value,incarnation:maps.get(value.id)?.get(value.incarnation)??value.incarnation}:null;if(change.kind==='field')return freeze({...change,target:record(change.target)!});if(change.kind==='element')return freeze({...change,before:record(change.before),after:record(change.after)});return change;};
}
function addConfirmedTraversal(intent:OperationIntent){
const event=eventFor(intent),id=event.transition.entryId,undo=[...history.undo],redo=[...history.redo],source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo;
if(!id||source.at(-1)!==id)throw fault('recovery_history_traversal_order',id?[id]:[]);
const entry=history.entries.get(id)!,before=event.before!.entries.get(id),after=event.after!.entries.get(id),changes=intent.origin==='undo'?invert(intent.changes):intent.changes;
// An applied action is a fact. Its history may move only if the reviewed prefix still denotes that exact forward effect.
if(!before||!after||!equal(entry.changes,before.changes)||!equal(changes,after.changes))throw fault('recovery_history_confirmed_traversal_conflict',[id]);
const entries=new Map(history.entries),map=incarnationRemap(before,after);for(const[otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(map)}));
entries.set(id,freeze({...entry,changes,operationIds:[...entry.operationIds,intent.id]}));source.pop();destination.push(id);history=state(undo,redo,entries);
}
return{
project(document:Document,intent:OperationIntent,context:IntentContext,keep:boolean){
if(!intent.changes.length)return{document,changes:[] as readonly Change[]};advance(intent);const event=eventFor(intent),id=event.transition.entryId;remap=undefined;