Replan native history events across repeated traversal and redo invalidation

This commit is contained in:
2026-11-08 03:30:00 +08:00
parent 231f70d87d
commit 1b29b0138b
8 changed files with 151 additions and 14 deletions
+1 -1
View File
@@ -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');
+2 -2
View File
@@ -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 -3
View File
@@ -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';
+1 -1
View File
@@ -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;},
};
}