|
|
|
@@ -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;i<events.length;i++)if(events[i].transition.rootRevision<=events[i-1].transition.rootRevision)throw fault('recovery_history_event_order');
|
|
|
|
|
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 pendingIds=new Set(pending.map(intent=>intent.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<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;};
|
|
|
|
|
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<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++;}
|
|
|
|
|
while(next<events.length&&(!intent||events[next].record?.intent.id!==intent.id)){
|
|
|
|
|
const event=events[next],record=event.record,before=history;
|
|
|
|
|
if(!record)applyAdjustment(event);
|
|
|
|
|
else{
|
|
|
|
|
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]);
|
|
|
|
|
}
|
|
|
|
|
recordStep(before,event.transition.entryId,record?.intent.id);next++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function incarnationRemap(before:HistoryEntry,after:HistoryEntry){
|
|
|
|
|
const maps=new Map<Id,Map<number,number>>();
|
|
|
|
@@ -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[]};
|
|
|
|
|
},
|
|
|
|
|