Replan native history events across repeated traversal and redo invalidation
This commit is contained in:
@@ -168,7 +168,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
const historyTransition=describeHistoryTransition(state.history,history,state.rootRevision+1,historyEntryId);
|
||||
const historyTransition=describeHistoryTransition(state.history,history,state.rootRevision+1,historyEntryId,true);
|
||||
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [],...(historyTransition?{historyTransition}:{}) });
|
||||
const prepared=options.prepareIntent?.(intent,state.runtime) ?? intent;
|
||||
if(prepared.operationType==='run.apply'&&!validJson(prepared.action)||!['document.change','run.apply'].includes(prepared.operationType)||['id','operationSchemaVersion','sequence','origin','canvasIds','changes','dependencies','historyTransition'].some(key=>!equal((prepared as unknown as Record<string,unknown>)[key],(intent as unknown as Record<string,unknown>)[key])))throw fault('invalid_prepared_intent');
|
||||
|
||||
@@ -12,10 +12,10 @@ function splice<T>(before:readonly T[],after:readonly T[]):Splice<T>{
|
||||
return{offset,beforeLength:before.length,afterLength:after.length,before:before.slice(offset,before.length-suffix),after:after.slice(offset,after.length-suffix)};
|
||||
}
|
||||
/** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
|
||||
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id):HistoryTransition|undefined{
|
||||
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{
|
||||
const entries:EntryPatch[]=[];
|
||||
for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});}
|
||||
if(!entries.length&&equal(before.undo,after.undo)&&equal(before.redo,after.redo))return undefined;
|
||||
if(!recordNoop&&!entries.length&&equal(before.undo,after.undo)&&equal(before.redo,after.redo))return undefined;
|
||||
return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
|
||||
}
|
||||
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { createHistoryJournalRecovery } from './history-journal-recovery.ts';
|
||||
import { replanTraversals } from './traversal-recovery.ts';
|
||||
import { operationCanvases, validateCanonicalRead } from './reconcile.ts';
|
||||
import { referencedResources } from './resources.ts';
|
||||
@@ -24,7 +25,7 @@ function value(document: Document, change: FieldChange, allowMissing=false): {be
|
||||
const documentValue = (document: Document) => ({projectId:document.projectId,canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:[...document.tombstones]});
|
||||
|
||||
/** Pure proposal. Explicit choices cover the complete pending dependency closure; no key/body is reused. */
|
||||
export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap<Id,RecoveryChoice>, registry: Registry, createId:()=>Id): ConflictPlan {
|
||||
export function planConflictRecovery(snapshot: EngineSnapshot, canonical: CanonicalProposal, choices: ReadonlyMap<Id,RecoveryChoice>, registry: Registry, createId:()=>Id,historyLimit=100): ConflictPlan {
|
||||
validateCanonicalRead(snapshot.runtime.acknowledged,snapshot.runtime.versions,canonical.document,canonical.versions,registry);
|
||||
if([...snapshot.runtime.ledger.requests.values()].some(request=>request.phase!=='resolved'))throw fault('recovery_waiting_for_receipts');
|
||||
const records=[...snapshot.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload').sort((a,b)=>a.intent.sequence-b.intent.sequence);
|
||||
@@ -36,6 +37,7 @@ 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 journal=records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)?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){
|
||||
const intent=record.intent,choice=choices.get(intent.id)!;
|
||||
@@ -48,7 +50,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
if(key&&!starts.has(key))starts.set(key,value(document,original,true));
|
||||
}
|
||||
if(choice==='keep-local'&&intent.origin==='user')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]);
|
||||
const rebased=choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]};
|
||||
const rebased=journal?journal.project(document,intent,contexts.get(intent.id)!,choice==='keep-local'):choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]};
|
||||
const changes=rebased.changes;document=rebased.document;
|
||||
if(entryId)boundaries.get(entryId)!.after=document;
|
||||
let replacement:OperationIntent|undefined;
|
||||
@@ -66,10 +68,11 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
operations.set(replacement.id,replacement);
|
||||
}
|
||||
}
|
||||
journal?.accept(intent,replacement,choice==='keep-local',before,document);
|
||||
replacements.set(intent.id,replacement);
|
||||
decisions.push(freeze({operationId:intent.id,choice,resolution:choice==='discard-local'?'discarded':replacement?'superseded':'satisfied',...(replacement?{replacementId:replacement.id}:{})}));
|
||||
}
|
||||
const pending=records.map(record=>record.intent),history=pending.some(intent=>['undo','redo'].includes(intent.origin)&&intent.changes.length)?replanTraversals(snapshot,pending,replacements,new Set(decisions.filter(decision=>decision.choice==='keep-local').map(decision=>decision.operationId)),boundaries,registry):rewriteHistory(snapshot,pending,replacements,starts,boundaries,registry);
|
||||
const pending=records.map(record=>record.intent),history=journal?journal.finish():pending.some(intent=>['undo','redo'].includes(intent.origin)&&intent.changes.length)?replanTraversals(snapshot,pending,replacements,new Set(decisions.filter(decision=>decision.choice==='keep-local').map(decision=>decision.operationId)),boundaries,registry):rewriteHistory(snapshot,pending,replacements,starts,boundaries,registry);
|
||||
ledger.retire(id,decisions);
|
||||
for(const replacement of replacements.values())if(replacement){
|
||||
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';
|
||||
|
||||
@@ -331,7 +331,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
...(conflict?{conflict}:{}),
|
||||
plan(choices:ReadonlyMap<Id,RecoveryChoice>){
|
||||
assertCurrent();if(committed)throw fault('recovery_already_committed');if(editor.getSnapshot().rootRevision!==root.rootRevision)throw fault('stale_plan');
|
||||
const plan=planConflictRecovery(root,canonical,choices,options.registry,createId);plans.add(plan);return plan;
|
||||
const plan=planConflictRecovery(root,canonical,choices,options.registry,createId,options.historyLimit??100);plans.add(plan);return plan;
|
||||
},
|
||||
async resolve(plan:ConflictPlan):Promise<Result>{
|
||||
assertCurrent();if(!plans.has(plan))throw fault('invalid_recovery_plan');if(committed&&committed!==plan)throw fault('recovery_already_committed');
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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 { 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 { 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');}
|
||||
let next=0;const touched=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;};
|
||||
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 prior=entries.get(id),appending=event.before!.entries.has(id);
|
||||
if(appending&&(!prior||undo.at(-1)!==id)||!appending&&prior)throw fault('recovery_history_traversal_order',[id]);
|
||||
let prefix=prior?.changes??[];
|
||||
if(pending&&prior&&!touched.has(id)&&document){
|
||||
if(prefix.every(change=>change.kind==='field')){
|
||||
const fields=prefix as readonly FieldChange[],last=new Map<string,FieldChange>();const key=(change:FieldChange)=>JSON.stringify([change.target.id,change.target.incarnation,change.field]);for(const change of fields)last.set(key(change),change);
|
||||
const keep=new Set([...last].filter(([,change])=>{const target=document.elements.get(change.target.id);if(target?.incarnation!==change.target.incarnation)return false;const field=change.field.startsWith('data.')?change.field.slice(5):undefined,value=field===undefined?(target as unknown as Record<string,unknown>)[change.field]:target.data[field]??null;return equal(value,change.after)&&(field===undefined||Object.hasOwn(target.data,field)===(change.afterPresent??true));}).map(([key])=>key));prefix=fields.filter(change=>keep.has(key(change)));
|
||||
}else try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[id]);}
|
||||
}
|
||||
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);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++;}
|
||||
}
|
||||
function incarnationRemap(before:HistoryEntry,after:HistoryEntry){
|
||||
const maps=new Map<Id,Map<number,number>>();
|
||||
const pair=(a:{id:Id;incarnation:number}|null,b:{id:Id;incarnation:number}|null)=>{if(a&&b&&a.id===b.id&&a.incarnation!==b.incarnation){const values=maps.get(a.id)??new Map();values.set(a.incarnation,b.incarnation);maps.set(a.id,values);}};
|
||||
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;};
|
||||
}
|
||||
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;
|
||||
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)!);
|
||||
}
|
||||
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.origin==='user'){if(replacement)addUser(intent,replacement.changes,replacement.id,true,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);
|
||||
history=state(undo,redo,entries);touched.add(id);
|
||||
}
|
||||
next++;
|
||||
},
|
||||
finish(){advance();return history;},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user