Retain acknowledged structural prefixes during conflict recovery
This commit is contained in:
@@ -5,6 +5,7 @@ import { clone, equal, fault, freeze, readMap, reject, validJson } from './value
|
||||
import { canvasTransform, convertGeometry } from './coordinates.ts';
|
||||
import { unitScale, withUnitScale } from './units.ts';
|
||||
import { removalChanges, repairConnectionOwners } from './structure.ts';
|
||||
import { planHistoryChanges } from './history.ts';
|
||||
|
||||
export type EditorOptions<Runtime = undefined> = {
|
||||
registry: Registry; document: Document; createId?: () => Id; historyLimit?: number;
|
||||
@@ -241,24 +242,11 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const allowed = options.traversalPolicy?.(entry, direction);
|
||||
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
|
||||
const planned=direction==='undo'?invert(entry.changes):entry.changes;
|
||||
const generations=new Map<Id,Map<number,number>>();
|
||||
const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;};
|
||||
const remap=(change:Change):Change=>{
|
||||
if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});}
|
||||
if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});}
|
||||
return change;
|
||||
};
|
||||
let document=state.document;const changes:Change[]=[];
|
||||
for(const original of planned){
|
||||
let change=remap(original);
|
||||
if(change.kind==='element'&&!change.before&&change.after){const dead=document.tombstones.get(change.id);if(dead!==undefined&&change.after.incarnation<=dead){const mapping=generations.get(change.id)??new Map<number,number>();mapping.set(change.after.incarnation,dead+1);generations.set(change.id,mapping);change=remap(change);}}
|
||||
document=applyChanges(document,[change]);changes.push(change);
|
||||
}
|
||||
validateDocument(document,options.registry);
|
||||
const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,options.registry);
|
||||
const entries=new Map(state.history.entries);
|
||||
// Later redo entries belong to the lifecycle restored by this explicit history action.
|
||||
// Frozen operations retain their original identities and incarnations.
|
||||
if(generations.size)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)}));
|
||||
if(restored)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:direction==='undo'?invert(changes):changes}));
|
||||
const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) });
|
||||
return publish(document, changes, direction, history, id);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Change, Document, ElementRecord, Id, Registry } from './types.ts';
|
||||
import { applyChanges, validateDocument } from './document.ts';
|
||||
import { freeze } from './values.ts';
|
||||
|
||||
/** Pure history execution, also used to validate recovery proposals before publication. */
|
||||
export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){
|
||||
const generations=new Map<Id,Map<number,number>>();
|
||||
const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;};
|
||||
const remap=(change:Change):Change=>{
|
||||
if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});}
|
||||
if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});}
|
||||
return change;
|
||||
};
|
||||
let document=initial;const changes:Change[]=[];
|
||||
for(const original of planned){
|
||||
let change=remap(original);
|
||||
if(change.kind==='element'&&!change.before&&change.after){const dead=document.tombstones.get(change.id);if(dead!==undefined&&change.after.incarnation<=dead){const mapping=generations.get(change.id)??new Map<number,number>();mapping.set(change.after.incarnation,dead+1);generations.set(change.id,mapping);change=remap(change);}}
|
||||
document=applyChanges(document,[change]);changes.push(change);
|
||||
}
|
||||
validateDocument(document,registry);
|
||||
return{document,changes:freeze(changes),remap,restored:generations.size>0};
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Change, Document, FieldChange, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.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 type { CanonicalProposal, EngineSnapshot } from './contracts.ts';
|
||||
import { createLedger, type Presence } from './ledger.ts';
|
||||
@@ -30,14 +32,15 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
const used=new Set([...snapshot.operations.keys(),...snapshot.runtime.ledger.operations.keys(),...snapshot.runtime.ledger.requests.keys(),...snapshot.history.entries.keys(),...(snapshot.runtime.recoveries?.keys()??[])]);
|
||||
function unique(){const id=createId();if(!id||used.has(id))throw fault('duplicate_operation_id');used.add(id);return id;}
|
||||
const id=unique(),ledger=createLedger(snapshot.runtime.ledger),operations=new Map(snapshot.operations),decisions:RecoveryDecision[]=[],replacements=new Map<Id,OperationIntent|undefined>();
|
||||
const starts=new Map<string,ReturnType<typeof value>>(),historyOwner=new Map<Id,Id>(),contexts=intentContexts(snapshot.document,records.map(record=>record.intent));
|
||||
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);}
|
||||
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)!;
|
||||
if(intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[intent.id]);
|
||||
if(intent.changes.length&&intent.origin!=='user')throw fault('recovery_history_traversal_not_supported',[intent.id]);
|
||||
const before=document;
|
||||
const before=document,entryId=historyOwner.get(intent.id);
|
||||
if(entryId&&!boundaries.has(entryId))boundaries.set(entryId,{before,after:before});
|
||||
for(const original of intent.changes)if(original.kind==='field'){
|
||||
const owner=historyOwner.get(intent.id),key=owner?JSON.stringify([owner,fieldKey(original)]):undefined;
|
||||
if(key&&!starts.has(key))starts.set(key,value(document,original,true));
|
||||
@@ -45,6 +48,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
if(choice==='keep-local')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 changes=rebased.changes;document=rebased.document;
|
||||
if(entryId)boundaries.get(entryId)!.after=document;
|
||||
let replacement:OperationIntent|undefined;
|
||||
if(choice==='keep-local'){
|
||||
if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]);
|
||||
@@ -62,7 +66,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
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 history=rewriteHistory(snapshot,records.map(record=>record.intent),replacements,starts);
|
||||
const history=rewriteHistory(snapshot,records.map(record=>record.intent),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';
|
||||
@@ -77,7 +81,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
return Object.freeze({id,baseRootRevision:snapshot.rootRevision,document,history,operations:readMap(operations),ledger:ledger.getSnapshot(),audit,resourceIds:Object.freeze([...resourceIds]),operationIds:Object.freeze([...replacements.values()].flatMap(intent=>intent?[intent.id]:[]))});
|
||||
}
|
||||
|
||||
function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap<Id,OperationIntent|undefined>,starts:ReadonlyMap<string,ReturnType<typeof value>>):HistoryState {
|
||||
function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap<Id,OperationIntent|undefined>,starts:ReadonlyMap<string,ReturnType<typeof value>>,boundaries:ReadonlyMap<Id,{before:Document;after:Document}>,registry:Registry):HistoryState {
|
||||
const entries=new Map(snapshot.history.entries),affected=new Set(pending.map(intent=>intent.id));
|
||||
for(const entry of snapshot.history.entries.values()){
|
||||
const intents=pending.filter(intent=>entry.operationIds.includes(intent.id));if(!intents.length)continue;
|
||||
@@ -87,7 +91,22 @@ function rewriteHistory(snapshot:EngineSnapshot,pending:readonly OperationIntent
|
||||
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
|
||||
continue;
|
||||
}
|
||||
if(intents.some(intent=>intent.changes.some(change=>change.kind!=='field'||['ownerCanvasId','source','target'].includes(change.field))))throw fault('recovery_history_structure_conflict',[entry.id]);
|
||||
const structural=(changes:readonly Change[])=>changes.some(change=>change.kind!=='field'||['ownerCanvasId','source','target'].includes(change.field));
|
||||
if(structural(entry.changes)||intents.some(intent=>structural(intent.changes))){
|
||||
// A grouped entry is chronological. Never splice a confirmed operation from after a pending one into its prefix.
|
||||
const first=entry.operationIds.findIndex(id=>affected.has(id));
|
||||
if(entry.operationIds.slice(first).some(id=>!affected.has(id)))throw fault('recovery_history_nonprefix_ack',[entry.id]);
|
||||
const oldPending=intents.flatMap(intent=>intent.changes),prefix=entry.changes.slice(0,entry.changes.length-oldPending.length);
|
||||
if(entry.changes.length<oldPending.length||!equal(entry.changes.slice(prefix.length),oldPending))throw fault('recovery_history_mismatch',[entry.id]);
|
||||
const boundary=boundaries.get(entry.id)!;
|
||||
const changes=[...prefix,...intents.flatMap(intent=>replacements.get(intent.id)?.changes??[])];
|
||||
// The acknowledged prefix is evidence, not a fresh command. Check its exact inverse rather than broadening it to remote additions.
|
||||
try{planHistoryChanges(boundary.before,invert(prefix),registry);planHistoryChanges(boundary.after,invert(changes),registry);}
|
||||
catch{throw fault('recovery_history_prefix_conflict',[entry.id]);}
|
||||
const operationIds=entry.operationIds.flatMap(id=>affected.has(id)?replacements.get(id)?[replacements.get(id)!.id]:[]:[id]);
|
||||
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
|
||||
continue;
|
||||
}
|
||||
const keys=new Map<string,FieldChange>();for(const intent of intents)for(const change of intent.changes as readonly FieldChange[])keys.set(fieldKey(change),change);
|
||||
const fields:FieldChange[]=[];
|
||||
for(const [key,example]of keys){
|
||||
|
||||
Reference in New Issue
Block a user