Recover user groups across nonprefix acknowledgments and discarded entry births

This commit is contained in:
2026-11-08 03:35:58 +08:00
parent 1b29b0138b
commit d99b6951cf
4 changed files with 65 additions and 19 deletions
+2 -1
View File
@@ -37,7 +37,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 journal=records.some(record=>['undo','redo'].includes(record.intent.origin)&&record.intent.historyTransition)?createHistoryJournalRecovery(snapshot,records.map(record=>record.intent),registry,historyLimit):undefined;
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 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){
const intent=record.intent,choice=choices.get(intent.id)!;
@@ -18,24 +18,33 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
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;
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;};
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))){
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);
for(const change of fields)if(['geometry','path'].includes(change.field)&&document.elements.get(change.target.id)?.ownerCanvasId!==snapshot.runtime.acknowledged.elements.get(change.target.id)?.ownerCanvasId)throw fault('recovery_history_prefix_conflict',[entry.id]);
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));return fields.filter(change=>keep.has(key(change)));
}
try{planHistoryChanges(document,invert(prefix),registry);}catch{throw fault('recovery_history_prefix_conflict',[entry.id]);}return prefix;
}
function skipUser(intent:OperationIntent,document:Document){
const event=eventFor(intent),id=event.transition.entryId;if(!id)return;const entry=history.entries.get(id);
if(!entry){retired.add(id);return;}
if(touched.has(id)||!history.undo.includes(id))return;
const changes=reviewPrefix(entry,document),entries=new Map(history.entries);if(changes.length)entries.set(id,freeze({...entry,changes}));else{entries.delete(id);retired.add(id);}history=state(history.undo.filter(key=>entries.has(key)),history.redo.filter(key=>entries.has(key)),entries);
}
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 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);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)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++;}
@@ -59,10 +68,10 @@ export function createHistoryJournalRecovery(snapshot:EngineSnapshot,pending:rea
},
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);}
if(intent.origin==='user'){if(replacement)addUser(intent,replacement.changes,replacement.id,true,before);else skipUser(intent,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);
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);retired.add(id);}
history=state(undo,redo,entries);touched.add(id);
}
next++;