Consume audited history evidence across repeated conflict recovery
This commit is contained in:
@@ -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<Id,OperationIntent|undefined>();
|
||||
const boundaries=new Map<Id,{before:Document;after:Document}>(),starts=new Map<string,ReturnType<typeof value>>(),historyOwner=new Map<Id,Id>(),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){
|
||||
|
||||
@@ -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<string>(),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;i<events.length;i++)if(events[i].transition.rootRevision<=events[i-1].transition.rootRevision)throw fault('recovery_history_event_order');order(events);return{events};
|
||||
}
|
||||
const audit=owners[0],revision=audit.baseRootRevision+1,replay=audit.historyReplay;
|
||||
if(!replay||!audit.historyTransition)throw fault('recovery_history_adoption_evidence_missing',[audit.id]);validateHistoryReplay(replay,revision);validateHistoryTransition(audit.historyTransition);
|
||||
if(audits.some(other=>other.baseRootRevision+1>revision))throw fault('recovery_history_adoption_stale',[audit.id]);
|
||||
const witnessed=new Set<string>(),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}};
|
||||
}
|
||||
@@ -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[]};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user