Files
Endoleap/src/lib/canvas-runtime/conflicts.ts
T

135 lines
15 KiB
TypeScript

import type { Change, Document, FieldChange, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
import { describeHistoryTransition, type HistoryTransition } 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 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';
export type RecoveryChoice = 'keep-local' | 'discard-local';
export type RecoveryDecision = { readonly operationId: Id; readonly choice: RecoveryChoice; readonly replacementId?: Id; readonly resolution: 'superseded' | 'discarded' | 'satisfied' };
export type RecoveryAudit = { readonly id: Id; readonly formatVersion: 1; readonly baseRootRevision: number; readonly decisions: readonly RecoveryDecision[]; readonly backup: Json; readonly historyTransition?:HistoryTransition };
export type ConflictPlan = { readonly id: Id; readonly baseRootRevision: number; readonly document: Document; readonly history: HistoryState; readonly operations: EngineSnapshot['operations']; readonly ledger: EngineSnapshot['runtime']['ledger']; readonly audit: RecoveryAudit; readonly resourceIds: readonly Id[]; readonly operationIds: readonly Id[] };
const fieldKey = (change: FieldChange) => JSON.stringify([change.target.id,change.target.incarnation,change.field]);
function value(document: Document, change: FieldChange, allowMissing=false): {before:Json;beforePresent?:boolean;missing?:boolean} {
const element=document.elements.get(change.target.id);
if(!element||element.incarnation!==change.target.incarnation){if(allowMissing)return{before:null,beforePresent:false,missing:true};throw fault('stale_incarnation',[change.target.id]);}
const key=change.field.startsWith('data.')?change.field.slice(5):undefined;
return {before:(key===undefined?(element as unknown as Record<string,Json>)[change.field]:element.data[key]??null),beforePresent:key===undefined?undefined:Object.hasOwn(element.data,key)};
}
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,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);
if(!records.length)throw fault('recovery_nothing_to_resolve');
for(const record of records)for(const dependency of record.intent.dependencies)if((snapshot.runtime.ledger.operations.get(dependency)?.intent.sequence??Infinity)>=record.intent.sequence)throw fault('recovery_dependency_order',[record.intent.id]);
if(choices.size!==records.length||records.some(record=>!['keep-local','discard-local'].includes(choices.get(record.intent.id)??'')))throw fault('recovery_choices_incomplete');
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 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)!;
if(intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[intent.id]);
if(intent.changes.length&&!['user','undo','redo'].includes(intent.origin))throw fault('recovery_history_traversal_not_supported',[intent.id]);
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));
}
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=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;
if(choice==='keep-local'){
if(intent.operationType!=='document.change'&&intent.operationType!=='run.apply')throw fault('recovery_action_requires_new_command',[intent.id]);
if(changes.length){
const dependencyIds=intent.dependencies.flatMap(dependency=>replacements.has(dependency)?replacements.get(dependency)?[replacements.get(dependency)!.id]:[]:[dependency]);
// New dependencies reference new logical facts; a discarded parent never silently starts a paid run.
const dependencySet=new Set(dependencyIds);
for(const previous of replacements.values())if(previous)dependencySet.add(previous.id);
const replacementId=unique();
const {historyTransition:_oldHistory,...originalFact}=intent;
const basic={...originalFact,id:replacementId,sequence:++sequence,changes:freeze(changes),dependencies:[...dependencySet],canvasIds:operationCanvases({...intent,canvasIds:[],changes},before,document)};
replacement=intent.operationType==='run.apply'?freeze({...basic,operationType:'run.apply',action:{...intent.action,targetCanvasId:document.elements.get(intent.action.target.id)!.ownerCanvasId}}) as OperationIntent:freeze(basic);
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=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';
ledger.add(replacement,presence);
}
// Conservative pins include the archived document/history and unresolved opaque local resources.
const resourceIds=new Set(snapshot.runtime.resources.keys());
for(const record of records)for(const resource of referencedResources(record.intent.changes,registry))resourceIds.add(resource);
for(const replacement of replacements.values())if(replacement)for(const resource of referencedResources(replacement.changes,registry))resourceIds.add(resource);
const backup=JSON.parse(JSON.stringify({document:documentValue(snapshot.document),acknowledged:documentValue(snapshot.runtime.acknowledged),versions:[...snapshot.runtime.versions],resourceIds:[...resourceIds],history:{undo:snapshot.history.undo,redo:snapshot.history.redo,entries:[...snapshot.history.entries]}})) as Json;
const historyTransition=describeHistoryTransition(snapshot.history,history,snapshot.rootRevision+1);
const audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup,...(historyTransition?{historyTransition}:{})});
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>>,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;
if(!snapshot.history.undo.includes(entry.id)||entry.operationIds.some(id=>snapshot.operations.get(id)?.origin!=='user'))throw fault('recovery_history_traversal_not_supported',[entry.id]);
if(entry.operationIds.every(id=>affected.has(id))){
const changes=intents.flatMap(intent=>replacements.get(intent.id)?.changes??[]),operationIds=intents.flatMap(intent=>replacements.get(intent.id)?[replacements.get(intent.id)!.id]:[]);
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
continue;
}
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){
if(entry.changes.some(change=>change.kind!=='field'&&change.id===example.target.id||change.kind==='field'&&change.target.id===example.target.id&&change.field==='ownerCanvasId'))throw fault('recovery_history_structure_not_supported',[entry.id]);
const original=entry.changes.filter((change):change is FieldChange=>change.kind==='field'&&fieldKey(change)===key);
const oldPending=intents.flatMap(intent=>intent.changes as readonly FieldChange[]).filter(change=>fieldKey(change)===key);
const prefix=original.slice(0,original.length-oldPending.length);
if(original.length<oldPending.length||!equal(original.slice(prefix.length),oldPending))throw fault('recovery_history_mismatch',[entry.id]);
const start=starts.get(JSON.stringify([entry.id,key]))!,first=oldPending[0];
if(!start.missing&&equal(start.before,first.before)&&(start.beforePresent??true)===(first.beforePresent??true))fields.push(...prefix);
fields.push(...intents.flatMap(intent=>replacements.get(intent.id)?.changes as readonly FieldChange[]??[]).filter(change=>fieldKey(change)===key));
}
const changes:Change[]=[...entry.changes.filter(change=>change.kind!=='field'||!keys.has(fieldKey(change))),...fields];
const operationIds=entry.operationIds.flatMap(operationId=>affected.has(operationId)?replacements.get(operationId)?[replacements.get(operationId)!.id]:[]:[operationId]);
if(changes.length)entries.set(entry.id,freeze({...entry,changes,operationIds}));else entries.delete(entry.id);
}
return Object.freeze({undo:Object.freeze(snapshot.history.undo.filter(id=>entries.has(id))),redo:snapshot.history.redo,entries:readMap(entries)});
}