Replay pending changes in one private document draft

This commit is contained in:
2026-11-10 17:45:34 +08:00
parent b735667e00
commit 0abb7c96ba
4 changed files with 76 additions and 23 deletions
+22 -1
View File
@@ -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})});}
+9 -22
View File
@@ -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);
}