Persist compact history transition evidence with recovery and resource pins
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -11,7 +12,7 @@ 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 };
|
||||
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} {
|
||||
@@ -59,7 +60,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
const dependencySet=new Set(dependencyIds);
|
||||
for(const previous of replacements.values())if(previous)dependencySet.add(previous.id);
|
||||
const replacementId=unique();
|
||||
const basic={...intent,id:replacementId,sequence:++sequence,changes:freeze(changes),dependencies:[...dependencySet],canvasIds:operationCanvases({...intent,canvasIds:[],changes},before,document)};
|
||||
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);
|
||||
}
|
||||
@@ -78,7 +80,8 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni
|
||||
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 audit=freeze({id,formatVersion:1 as const,baseRootRevision:snapshot.rootRevision,decisions,backup});
|
||||
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]:[]))});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import type { RecoveryAudit } from './conflicts.ts';
|
||||
import { validateHistoryTransition } from '../canvas-core/history-transition.ts';
|
||||
import { createDocument, validateDocument } from '../canvas-core/document.ts';
|
||||
import { fault, freeze, readMap, validJson, equal } from '../canvas-core/values.ts';
|
||||
import type { EngineSnapshot, RuntimeState } from './contracts.ts';
|
||||
@@ -53,6 +54,11 @@ function changes(value: unknown): readonly Change[] {
|
||||
}
|
||||
return freeze(value as Change[]);
|
||||
}
|
||||
function historyTransition(value:unknown){
|
||||
const raw=object(value);integer(raw.version,1);
|
||||
// Preserve future evidence with its original bytes. Its interpreter must be checked before any replay.
|
||||
if(raw.version===1){try{validateHistoryTransition(value,changes);}catch{throw fault('corrupt_recovery_image');}}
|
||||
}
|
||||
function intent(value: unknown, id: Id): OperationIntent {
|
||||
const raw = object(value);
|
||||
if (string(raw.id) !== id) throw fault('corrupt_recovery_image');
|
||||
@@ -64,6 +70,7 @@ function intent(value: unknown, id: Id): OperationIntent {
|
||||
if(kind==='run.cancel'){if(array(raw.changes).length||array(raw.canvasIds).length)throw fault('corrupt_recovery_image');}
|
||||
else {const target=object(action.target);string(target.id);integer(target.incarnation,1);string(action.outputId);if(!strings(raw.canvasIds).includes(string(action.targetCanvasId)))throw fault('corrupt_recovery_image');}
|
||||
}
|
||||
if(raw.historyTransition!==undefined)historyTransition(raw.historyTransition);
|
||||
integer(raw.operationSchemaVersion, 1); integer(raw.sequence, 1); string(raw.origin);
|
||||
strings(raw.canvasIds); strings(raw.dependencies); changes(raw.changes);
|
||||
return freeze(value as OperationIntent);
|
||||
@@ -81,9 +88,11 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
// Own the parsed records; mutating a caller's RecoveryImage must not mutate live state.
|
||||
const raw = object(JSON.parse(JSON.stringify(image.payload))), document = readDocument(raw.document, registry), history = object(raw.history), runtime = object(raw.runtime), ledger = object(runtime.ledger);
|
||||
const operations = table(raw.operations, intent),restoredHistory=readHistory(history,operations);
|
||||
for(const operation of operations.values())if(operation.historyTransition?.version===1&&(operation.historyTransition.rootRevision<1||operation.historyTransition.rootRevision>image.rootRevision))throw fault('corrupt_recovery_image');
|
||||
const records = table<OperationRecord>(ledger.operations, (value, id) => {
|
||||
const raw = object(value); intent(raw.intent, id); strings(raw.blockers); object(raw.basePresence);
|
||||
if (((object(raw.intent).operationType === 'document.change'||object(raw.intent).operationType==='run.apply'&&array(object(raw.intent).changes).length>0) && !operations.has(id)) || !['pending', 'frozen', 'resolved', 'blocked'].includes(string(raw.phase))) throw fault('corrupt_recovery_image');
|
||||
if(operations.has(id)&&!equal(operations.get(id)!.historyTransition,(raw.intent as OperationIntent).historyTransition))throw fault('corrupt_recovery_image');
|
||||
if(raw.resolution!==undefined&&!['applied','not-applied','superseded','discarded','satisfied'].includes(string(raw.resolution)))throw fault('corrupt_recovery_image');
|
||||
return freeze(value as OperationRecord);
|
||||
});
|
||||
@@ -153,7 +162,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
|
||||
const resourcePins = table(runtime.resourcePins ?? [], strings);
|
||||
const replacementOwners=new Set<Id>();
|
||||
const recoveries=table<RecoveryAudit>(runtime.recoveries??[],(value,id)=>{
|
||||
const raw=object(value),backup=object(raw.backup);if(string(raw.id)!==id||raw.formatVersion!==1||integer(raw.baseRootRevision)>=image.rootRevision)throw fault('corrupt_recovery_image');
|
||||
const raw=object(value),backup=object(raw.backup);if(raw.historyTransition!==undefined){historyTransition(raw.historyTransition);if(object(raw.historyTransition).version===1&&object(raw.historyTransition).rootRevision!==(raw.baseRootRevision as number)+1)throw fault('corrupt_recovery_image');}if(string(raw.id)!==id||raw.formatVersion!==1||integer(raw.baseRootRevision)>=image.rootRevision)throw fault('corrupt_recovery_image');
|
||||
const working=readDocument(backup.document,registry),base=readDocument(backup.acknowledged,registry),versions=table(backup.versions,value=>integer(value,1));
|
||||
if(working.projectId!==document.projectId||base.projectId!==document.projectId||[...base.canvases.keys()].some(id=>!versions.has(id)))throw fault('corrupt_recovery_image');
|
||||
readHistory(object(backup.history),operations);if(!equal(strings(backup.resourceIds),resourcePins.get(`recovery:${id}`)))throw fault('corrupt_recovery_image');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Change, CoreSnapshot, Document, ElementRecord, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { historyTransitionChanges } from '../canvas-core/history-transition.ts';
|
||||
import { ancestors } from '../canvas-core/document.ts';
|
||||
import type { FrozenRequest, LedgerSnapshot } from './ledger.ts';
|
||||
import type { OperationStatus } from './scheduler.ts';
|
||||
@@ -88,6 +89,11 @@ export function resourcePins(snapshot: CoreSnapshot, ledger: LedgerSnapshot, reg
|
||||
if (record.intent.operationType === 'asset.upload') pin(record.intent.resourceId, `operation:${record.intent.id}`);
|
||||
else changes(record.intent.changes, `operation:${record.intent.id}`);
|
||||
}
|
||||
const pending=[...ledger.operations.values()].filter(record=>record.phase!=='resolved'),earliest=pending.reduce((value,record)=>Math.min(value,record.intent.sequence),Infinity);
|
||||
for(const record of ledger.operations.values())if(record.intent.sequence>=earliest&&record.intent.historyTransition){
|
||||
if(record.intent.historyTransition.version!==1){complete=false;continue;}
|
||||
changes(historyTransitionChanges(record.intent.historyTransition),`history-transition:${record.intent.id}`);
|
||||
}
|
||||
for (const [owner, ids] of explicit) for (const id of ids) pin(id, owner);
|
||||
return Object.freeze({ complete, pins: readMap(new Map([...pins].map(([id, owners]) => [id, readSet(owners)]))) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user