Replay pending changes in one private document draft
This commit is contained in:
@@ -97,9 +97,22 @@ export function validateDocument(document:Document,registry:Registry):void{
|
||||
if(roots!==1)throw fault('invalid_roots');
|
||||
for(const element of document.elements.values())validateElementInDocument(document,element,registry,true);
|
||||
}
|
||||
export type ReplayChangeBatch = {readonly changes:readonly Change[];readonly canvasIds?:readonly Id[]};
|
||||
export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
|
||||
return applyChangeBatches(document,[{changes}],conditional,false);
|
||||
}
|
||||
/** Ordered conditional replay against one private draft; no intermediate document escapes. */
|
||||
export function replayChanges(document:Document,batches:Iterable<ReplayChangeBatch>):Document {
|
||||
return applyChangeBatches(document,batches,true,true);
|
||||
}
|
||||
function applyChangeBatches(document:Document,batches:Iterable<ReplayChangeBatch>,conditional:boolean,replay:boolean):Document {
|
||||
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
|
||||
for (const change of changes) {
|
||||
for (const batch of batches) for (const change of batch.changes) {
|
||||
if(replay){
|
||||
// Check scope even when the desired field value is already present.
|
||||
if(change.kind==='field'){const owner=elements.get(change.target.id)?.ownerCanvasId;if(owner&&batch.canvasIds?.length&&!batch.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);}
|
||||
if(changeSatisfied(elements,canvases,change))continue;
|
||||
}
|
||||
if (change.kind === 'field') {
|
||||
if (!validJson(change.after)) throw fault('invalid_change');
|
||||
if (!change.field.startsWith('data.') && !['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(change.field)) throw fault('invalid_field');
|
||||
@@ -136,6 +149,14 @@ export function applyChanges(document: Document, changes: readonly Change[], con
|
||||
}
|
||||
return documentEnvelope({ projectId: document.projectId, elements: readMap(elements), canvases: readMap(canvases), tombstones: readMap(tombstones) });
|
||||
}
|
||||
function changeSatisfied(elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>,change:Change):boolean {
|
||||
if(change.kind==='canvas')return equal(canvases.get(change.id)??null,change.after);
|
||||
if(change.kind==='element')return equal(elements.get(change.id)??null,change.after);
|
||||
const element=elements.get(change.target.id);
|
||||
if(!element||element.incarnation!==change.target.incarnation)return false;
|
||||
if(change.field.startsWith('data.')){const key=change.field.slice(5);return Object.hasOwn(element.data,key)===(change.afterPresent??true)&&equal(element.data[key]??null,change.after);}
|
||||
return equal((element as unknown as Record<string,unknown>)[change.field],change.after);
|
||||
}
|
||||
export function invert(changes: readonly Change[]): readonly Change[] {
|
||||
return changes.slice().reverse().map(change => {
|
||||
if(change.kind==='field'){const{beforePresent,afterPresent,...record}=change;return freeze({...record,before:change.after,after:change.before,...(afterPresent===undefined?{}:{beforePresent:afterPresent}),...(beforePresent===undefined?{}:{afterPresent:beforePresent})});}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { withLocalLifecycles } from './elision.ts';
|
||||
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
|
||||
import type { Change, Document, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { applyChanges, validateDocument } from '../canvas-core/document.ts';
|
||||
import type { Document, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
import { replayChanges, validateDocument } from '../canvas-core/document.ts';
|
||||
import { equal, fault } from '../canvas-core/values.ts';
|
||||
import type { LedgerSnapshot } from './ledger.ts';
|
||||
|
||||
@@ -53,27 +53,14 @@ export function operationCanvases(intent: OperationIntent, document: Document, a
|
||||
|
||||
/** Replay once, by logical sequence. A receipt can already contain a desired field value. */
|
||||
export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document {
|
||||
let document = withLocalLifecycles(base, ledger);
|
||||
for (const record of [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) {
|
||||
if (record.phase === 'resolved' || !['document.change','run.apply'].includes(record.intent.operationType)) continue;
|
||||
if (record.intent.historyEffect) validateCreationSizeEffect(record.intent.historyEffect);
|
||||
for (const change of record.intent.changes) {
|
||||
if(change.kind==='field'){const owner=document.elements.get(change.target.id)?.ownerCanvasId;if(owner&&record.intent.canvasIds?.length&&!record.intent.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);}
|
||||
if (satisfied(document, change)) continue;
|
||||
document = applyChanges(document, [change]);
|
||||
function* pending(){
|
||||
for(const record of [...ledger.operations.values()].sort((a,b)=>a.intent.sequence-b.intent.sequence)){
|
||||
if(record.phase==='resolved'||!['document.change','run.apply'].includes(record.intent.operationType))continue;
|
||||
if(record.intent.historyEffect)validateCreationSizeEffect(record.intent.historyEffect);
|
||||
yield {changes:record.intent.changes,canvasIds:record.intent.canvasIds};
|
||||
}
|
||||
}
|
||||
validateDocument(document, registry);
|
||||
const document=replayChanges(withLocalLifecycles(base,ledger),pending());
|
||||
validateDocument(document,registry);
|
||||
return document;
|
||||
}
|
||||
function satisfied(document: Document, change: Change) {
|
||||
if (change.kind === 'canvas') return equal(document.canvases.get(change.id) ?? null, change.after);
|
||||
if (change.kind === 'element') return equal(document.elements.get(change.id) ?? null, change.after);
|
||||
const element = document.elements.get(change.target.id);
|
||||
if (!element || element.incarnation !== change.target.incarnation) return false;
|
||||
if (change.field.startsWith('data.')) {
|
||||
const key = change.field.slice(5);
|
||||
return Object.hasOwn(element.data, key) === (change.afterPresent ?? true) && equal(element.data[key] ?? null, change.after);
|
||||
}
|
||||
return equal((element as unknown as Record<string, unknown>)[change.field], change.after);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user