feat(canvas): compact mutable batches with conditional wire projections

This commit is contained in:
2026-11-08 04:40:05 +08:00
parent 95a5cd5697
commit 911931d265
6 changed files with 194 additions and 27 deletions
@@ -0,0 +1,63 @@
import type { Change, Document, ElementRecord, Id, OperationIntent } from '../canvas-core/types.ts';
import { applyChanges } from '../canvas-core/document.ts';
import { equal, fault, freeze } from '../canvas-core/values.ts';
import { compressChanges, type LedgerSnapshot } from './ledger.ts';
const encoder = new TextEncoder();
const wireBytes = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength;
export type WireOperation = { readonly id: Id; readonly changes: readonly Change[] };
const sameMap = <T>(a: ReadonlyMap<Id, T>, b: ReadonlyMap<Id, T>) => a.size === b.size && [...a].every(([id, value]) => b.has(id) && equal(value, b.get(id)));
/** Pure projection for an already-selected mutable segment. Original ledger/history records never change. */
export function projectBatch(intents: readonly OperationIntent[], acknowledged: Document, ledger: LedgerSnapshot, participants: readonly Id[]): readonly WireOperation[] {
const scope = new Set(participants);
const ordered = intents.map(({ id, changes }) => ({ id, changes }));
if (intents.length < 2) return ordered;
const elements = new Map<Id, ElementRecord>();
const canvases = new Map(acknowledged.canvases.has(acknowledged.projectId) ? [[acknowledged.projectId, acknowledged.canvases.get(acknowledged.projectId)!]] : []);
const tombstones = new Map<Id, number>(), seen = new Set<Id>();
for (const intent of intents) {
const record = ledger.operations.get(intent.id);
// A missing/unknown baseline, frozen identity, or blocked intent cannot be guessed away.
if (!record || record.phase !== 'pending' || record.requestId || record.blockers.length || !equal(record.intent.changes, intent.changes)) return ordered;
for (const change of intent.changes) {
const id = change.kind === 'field' ? change.target.id : change.id;
if (change.kind === 'canvas') { const canvas = acknowledged.canvases.get(id); if (canvas) canvases.set(id, canvas); continue; }
if (seen.has(id)) continue;
seen.add(id);
const element = acknowledged.elements.get(id), presence = record.basePresence[id];
if (!Object.hasOwn(record.basePresence, id) || (presence !== 'absent' && presence !== 'exists') || (presence === 'exists') !== !!element) return ordered;
if (element) elements.set(id, element);
if (acknowledged.tombstones.has(id)) tombstones.set(id, acknowledged.tombstones.get(id)!);
}
}
const sparse: Document = { projectId: acknowledged.projectId, elements, canvases, tombstones };
const raw = intents.flatMap(intent => intent.changes);
// Check the original conditional chain before any transformation can remove a check.
const original = applyChanges(sparse, raw);
const compacted = compressChanges(raw, { preserveLifecycle: true, preserveChecks: true });
if (!compacted.length) return ordered;
// Protocol 2 requires a nonempty change list per logical ID. Prefix IDs get an explicit
// conditional no-op; the final ID carries the composed effect. All IDs remain deduplicated.
const guards: Change[] = [];
let candidates = 0;
for (const element of elements.values()) if (scope.has(element.ownerCanvasId)) {
if (++candidates > 8) break;
for (const key of ['title', 'text']) {
const present = Object.hasOwn(element.data, key), value = element.data[key] ?? null;
guards.push({ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: `data.${key}`, before: value, after: value, beforePresent: present, afterPresent: present });
}
}
const root = canvases.get(acknowledged.projectId);
// A child canvas no-op would touch its parent. Never silently widen the selected claims.
if (root && root.parent === null && scope.has(root.id)) guards.push({ kind: 'canvas', id: root.id, before: root, after: root });
let guard: Change | undefined, guardBytes = Infinity;
for (const candidate of guards) { const bytes = wireBytes(candidate); if (bytes < guardBytes) { guard = candidate; guardBytes = bytes; } }
if (!guard) return ordered;
const projected = intents.map((intent, index) => ({ id: intent.id, changes: index === intents.length - 1 ? compacted : [guard!] }));
if (projected.reduce((count, operation) => count + operation.changes.length, 0) > 10_000 || wireBytes(projected) >= wireBytes(ordered)) return ordered;
const result = applyChanges(sparse, projected.flatMap(operation => operation.changes));
if (!sameMap(original.elements, result.elements) || !sameMap(original.canvases, result.canvases) || !sameMap(original.tombstones, result.tombstones)) throw fault('batch_projection_mismatch');
return freeze(projected);
}
+17 -20
View File
@@ -1,3 +1,4 @@
import { applyChanges } from '../canvas-core/document.ts';
import type { Change, Id, Json, OperationIntent } from '../canvas-core/types.ts';
import { clone, equal, fault, freeze, readMap, validJson } from '../canvas-core/values.ts';
@@ -16,30 +17,26 @@ export type OperationRecord = { readonly intent: OperationIntent; readonly phase
export type LedgerSnapshot = { readonly generation: number; readonly durableGeneration: number; readonly operations: ReadonlyMap<Id, OperationRecord>; readonly requests: ReadonlyMap<Id, FrozenRequest> };
/** A field-safe compressor for a wholly unsent segment. Frozen records never enter here. */
export function compressChanges(changes: readonly Change[]): readonly Change[] {
const result: Change[] = [];
export function compressChanges(changes: readonly Change[], options: { preserveLifecycle?: boolean; preserveChecks?: boolean } = {}): readonly Change[] {
const result: (Change | undefined)[] = [], positions = new Map<string, number[]>();
for (const change of changes) {
const id = change.kind === 'field' ? change.target.id : change.id;
const previousIndex = result.findLastIndex(item => (item.kind === 'field' ? item.target.id : item.id) === id);
const previous = result[previousIndex];
const key = `${change.kind === 'canvas' ? 'canvas' : 'element'}:${id}`, stack = positions.get(key) ?? [];
const previousIndex = stack.at(-1), previous = previousIndex === undefined ? undefined : result[previousIndex];
const removePrevious = () => { result[previousIndex!] = undefined; stack.pop(); };
if (previous?.kind === 'element' && previous.after && !previous.before && change.kind === 'field') {
// Reuse the same controlled field application semantics; no full-node stale overwrite.
const after = clone(previous.after);
if (change.target.incarnation !== after.incarnation) throw fault('stale_incarnation', [id]);
if (change.field.startsWith('data.')) {
const key = change.field.slice(5), data = { ...after.data };
if (!key || ['__proto__','prototype','constructor'].includes(key)) throw fault('invalid_field');
if (change.afterPresent === false) delete data[key]; else data[key] = clone(change.after);
result[previousIndex] = freeze({ ...previous, after: { ...after, data } });
} else result[previousIndex] = freeze({ ...previous, after: { ...after, [change.field]: clone(change.after) } }) as Change;
} else if (previous?.kind === 'element' && previous.after && !previous.before && change.kind === 'element' && !change.after && equal(previous.after, change.before)) {
result.splice(previousIndex, 1);
} else if (previous?.kind === 'field' && change.kind === 'field' && previous.field === change.field && previous.target.incarnation === change.target.incarnation && equal(previous.after, change.before) && previous.afterPresent === change.beforePresent) {
const merged = freeze({ ...previous, after: change.after, afterPresent: change.afterPresent });
if (equal(merged.before, merged.after) && merged.beforePresent === merged.afterPresent) result.splice(previousIndex, 1); else result[previousIndex] = merged;
} else result.push(change);
// Apply the same conditional field semantics as the document; never discard a failed before check.
const document = { projectId: '', elements: new Map([[id, previous.after]]), canvases: new Map(), tombstones: new Map() };
const after = applyChanges(document, [change]).elements.get(id)!;
result[previousIndex!] = freeze({ ...previous, after });
} else if (previous?.kind === 'element' && previous.after && !previous.before && change.kind === 'element' && !change.after && equal(previous.after, change.before) && !options.preserveLifecycle) {
removePrevious();
} else if (previous?.kind === 'field' && change.kind === 'field' && previous.field === change.field && previous.target.incarnation === change.target.incarnation && equal(previous.after, change.before) && (previous.afterPresent ?? true) === (change.beforePresent ?? true)) {
const merged = freeze({ ...previous, after: clone(change.after), afterPresent: change.afterPresent ?? true });
if (!options.preserveChecks && equal(merged.before, merged.after) && (merged.beforePresent ?? true) === (merged.afterPresent ?? true)) removePrevious(); else result[previousIndex!] = merged;
} else { stack.push(result.length); positions.set(key, stack); result.push(clone(change)); }
}
return freeze(result);
return freeze(result.filter((change): change is Change => change !== undefined));
}
export function createLedger(initial?: LedgerSnapshot) {
+2 -1
View File
@@ -3,6 +3,7 @@ import { createDocument, validateDocument } from '../canvas-core/document.ts';
import { equal, fault, freeze, readMap, validJson } from '../canvas-core/values.ts';
import type { AppliedModelProof, EngineSnapshot, PersistencePort } from '../canvas-runtime/contracts.ts';
import type { FrozenRequest } from '../canvas-runtime/ledger.ts';
import { projectBatch } from '../canvas-runtime/batch-projection.ts';
import type { OperationStatus } from '../canvas-runtime/scheduler.ts';
export type ServiceRequest = { readonly path: string; readonly method: 'GET' | 'POST'; readonly requestId?: Id; readonly expectedVersion?: number; readonly body?: string | FormData; readonly resourceId?: Id; readonly signal?: AbortSignal };
@@ -123,7 +124,7 @@ export function createFoundryPersistence(options: { workspaceId: Id; rootId: Id;
expectedVersions[canvas] = version ?? 0;
}
// The body is the durable coverage mapping. Never replace logical IDs with a synthetic batch ID.
const payload = JSON.parse(JSON.stringify({ protocol_version: 2, request_id: requestId, expected_versions: expectedVersions, operations: intents.map(intent => ({ id: intent.id, changes: intent.changes })) })) as Json;
const payload = JSON.parse(JSON.stringify({ protocol_version: 2, request_id: requestId, expected_versions: expectedVersions, operations: projectBatch(intents, snapshot.runtime.acknowledged, snapshot.runtime.ledger, canvasIds) })) as Json;
if (!validJson(payload)) throw fault('invalid_operation_payload');
return freeze({ protocolVersion: 2, canvasIds, expectedVersions, payload });
}
+92
View File
@@ -0,0 +1,92 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDocument, applyChanges } from '../../src/lib/canvas-core/document.ts';
import { createLedger, compressChanges } from '../../src/lib/canvas-runtime/ledger.ts';
import { projectBatch } from '../../src/lib/canvas-runtime/batch-projection.ts';
const root='root', child='child';
const node=(id='a',ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'original',future:{keep:true}},geometry:{x:0,y:0,width:100,height:200,rotation:0}});
const document=createDocument(root,[{id:root,parent:null}],[node()]);
const field=(before,after,key='data.text',extra={})=>({kind:'field',target:{id:'a',incarnation:1},field:key,before,after,...extra});
const long='intermediate '.repeat(200);
function fixture(changes,base=document,presence){
const ledger=createLedger();
const intents=changes.map((changes,index)=>({id:`op-${index}`,operationType:'document.change',operationSchemaVersion:1,sequence:index+1,origin:'user',canvasIds:[root],dependencies:index?[`op-${index-1}`]:[],changes}));
const seen=new Set();
for(const intent of intents){const bases={};for(const change of intent.changes){if(change.kind==='canvas')continue;const id=change.kind==='field'?change.target.id:change.id;bases[id]=presence??(seen.has(id)||base.elements.has(id)?'exists':'absent');seen.add(id);}ledger.add(intent,bases);}
return{ledger,intents,project:(scope=[root])=>projectBatch(intents,base,ledger.getSnapshot(),scope)};
}
const values=document=>({elements:[...document.elements],canvases:[...document.canvases],tombstones:[...document.tombstones]});
test('wire projection composes long field edits, preserves every original logical ID and leaves ledger/history inputs untouched',()=>{
const f=fixture([[field('original',long)],[field(long,'final')]]),before=JSON.stringify([...f.ledger.getSnapshot().operations]),wire=f.project();
assert.deepEqual(wire.map(op=>op.id),f.intents.map(op=>op.id));
assert.deepEqual(wire.at(-1).changes,[field('original','final','data.text',{afterPresent:true})]);
assert.ok(JSON.stringify(wire).length<JSON.stringify(f.intents.map(({id,changes})=>({id,changes}))).length);
assert.equal(JSON.stringify([...f.ledger.getSnapshot().operations]),before);
assert.deepEqual(values(applyChanges(document,wire.flatMap(op=>op.changes))),values(applyChanges(document,f.intents.flatMap(op=>op.changes))));
assert.equal(applyChanges(document,wire.flatMap(op=>op.changes)).elements.get('a').data.future.keep,true);
});
test('position-only compaction keeps title, opaque data and the original before condition',()=>{
const start=node().geometry,middle={...start,x:12345,y:67890},final={...start,x:3,y:4},f=fixture([[field(start,middle,'geometry')],[field(middle,final,'geometry')]]),wire=f.project();
assert.equal(wire.at(-1).changes.length,1);assert.deepEqual(wire.at(-1).changes[0].before,start);
const result=applyChanges(document,wire.flatMap(op=>op.changes));assert.deepEqual(result.elements.get('a').data,node().data);assert.deepEqual(result.elements.get('a').geometry,final);
});
test('created records absorb checked updates while create/delete keeps tombstone and lifecycle evidence',()=>{
const created={...node('new'),data:{title:'new',text:long}},updated={...created,data:{...created.data,text:'final'}};
const f=fixture([[{kind:'element',id:'new',before:null,after:created}],[{...field(long,'final'),target:{id:'new',incarnation:1}}],[{kind:'element',id:'new',before:updated,after:null}]]),wire=f.project();
const result=applyChanges(document,wire.flatMap(op=>op.changes));assert.equal(result.elements.has('new'),false);assert.equal(result.tombstones.get('new'),1);
assert.ok(wire.at(-1).changes.some(change=>change.kind==='element'&&change.after));assert.ok(wire.at(-1).changes.some(change=>change.kind==='element'&&change.after===null));
assert.deepEqual(values(result),values(applyChanges(document,f.intents.flatMap(op=>op.changes))));
});
test('net-zero field changes retain conditional presence checks; omitted true flags compose with explicit true flags',()=>{
const changes=[field(null,long,'data.optional',{beforePresent:false}),field(long,null,'data.optional',{beforePresent:true,afterPresent:false})];
const compressed=compressChanges(changes,{preserveChecks:true,preserveLifecycle:true});assert.equal(compressed.length,1);assert.equal(compressed[0].beforePresent,false);assert.equal(compressed[0].afterPresent,false);
assert.deepEqual(values(applyChanges(document,compressed)),values(applyChanges(document,changes)));
const f=fixture([[changes[0]],[changes[1]]]);assert.equal(f.project().at(-1).changes[0].beforePresent,false);
});
test('unknown or inconsistent baselines and frozen or blocked records retain ordered original changes',()=>{
for(const presence of ['unknown','absent']){
const f=fixture([[field('original',long)],[field(long,'final')]],document,presence);assert.deepEqual(f.project(),f.intents.map(({id,changes})=>({id,changes})));
}
const missing=fixture([[field('original',long)],[field(long,'final')]]),snapshot=missing.ledger.getSnapshot(),operations=new Map(snapshot.operations);
operations.set('op-0',{...operations.get('op-0'),basePresence:{}});
assert.deepEqual(projectBatch(missing.intents,document,{...snapshot,operations},[root]),missing.intents.map(({id,changes})=>({id,changes})));
for(const state of ['frozen','blocked']){
const f=fixture([[field('original',long)],[field(long,'final')]]);
if(state==='frozen')f.ledger.freeze({id:'request',operationIds:['op-0'],canvasIds:[root],expectedVersions:{[root]:1},payload:{}});else f.ledger.block('op-0',['permission']);
assert.deepEqual(f.project(),f.intents.map(({id,changes})=>({id,changes})));
}
});
test('empty child creation does not borrow a parent canvas guard or widen participant claims',()=>{
const base=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'frame'}}],[{...node('frame'),componentId:'frame'}]);
const created={...node('new',child),data:{title:'new',text:long}},f=fixture([[{kind:'element',id:'new',before:null,after:created}],[{...field(long,'final'),target:{id:'new',incarnation:1}}]],base);
assert.deepEqual(f.project([child]),f.intents.map(({id,changes})=>({id,changes})));
});
test('invalid original before values and stale incarnations cannot become successful compacted creates',()=>{
const created={...node('new'),data:{title:'new',text:long}};
for(const change of [{...field('wrong','final'),target:{id:'new',incarnation:1}},{...field(long,'final'),target:{id:'new',incarnation:2}}]){
assert.throws(()=>compressChanges([{kind:'element',id:'new',before:null,after:created},change]),/field_conflict|stale_incarnation/);
const f=fixture([[{kind:'element',id:'new',before:null,after:created}],[change]]);assert.throws(()=>f.project(),/field_conflict|stale_incarnation/);
}
});
test('projection checks only affected records and leaves a large untouched document outside its sparse proof',()=>{
const elements=new Map(document.elements);elements.set('untouched',{...node('untouched'),data:{title:'opaque',text:'large'}});
const guarded={...document,elements:{get:id=>{assert.notEqual(id,'untouched');return elements.get(id);},has:id=>elements.has(id),[Symbol.iterator](){throw new Error('full_document_scan');}}};
const f=fixture([[field('original',long)],[field(long,'final')]],guarded);assert.equal(f.project().at(-1).changes[0].after,'final');
});
test('wire cost uses UTF-8 bytes, so a Unicode guard cannot turn a shorter JS string into a larger request',()=>{
const a={...node('a',child),data:{title:'😀'.repeat(50),text:'😀'.repeat(50),blob:'x'}};
const base=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'frame'}}],[{...node('frame'),componentId:'frame'},a]);
const middle='m'.repeat(150),f=fixture([[field('x',middle,'data.blob')],[field(middle,'final','data.blob')]],base),wire=f.project([child]);
assert.deepEqual(wire,f.intents.map(({id,changes})=>({id,changes})));
});
+6 -5
View File
@@ -296,6 +296,7 @@ async function resourceRecoveryCase(name, assert) {
async function batchRecoveryCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument(root,[{id:root,parent:null}],[node]);
const firstTitle='first '.repeat(100),secondTitle='second '.repeat(100);
const database=`${name}-batch-recovery`,storage=createBrowserStorage({name:database}),calls=[],receipts=new Map();let server=document,version=1,lose=true,engine;
const transport=async request=>{
calls.push(request);
@@ -308,17 +309,17 @@ async function batchRecoveryCase(name,assert){
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);
const first=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'first'}}),second=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'second'}});
assert(engine.getSnapshot().document.elements.get('a').data.title==='second','local publication waited for batch preparation');await engine.settle();
const first=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:firstTitle}}),second=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:secondTitle}});
assert(engine.getSnapshot().document.elements.get('a').data.title===secondTitle,'local publication waited for batch preparation');await engine.settle();
const frozen=[...engine.getSnapshot().runtime.ledger.requests.values()][0];
assert(calls.length===1&&frozen.operationIds.length===2&&frozen.operationIds[0]===first.operationIds[0]&&frozen.operationIds[1]===second.operationIds[0],'logical edits were not covered by one ordered request');assert(version===2,'batch advanced a canvas version per operation');
assert(calls.length===1&&frozen.operationIds.length===2&&frozen.operationIds[0]===first.operationIds[0]&&frozen.operationIds[1]===second.operationIds[0],'logical edits were not covered by one ordered request');assert(version===2,'batch advanced a canvas version per operation');const wire=JSON.parse(frozen.body).operations,raw=frozen.operationIds.map(id=>({id,changes:engine.getSnapshot().runtime.ledger.operations.get(id).intent.changes}));assert(JSON.stringify(wire).length<JSON.stringify(raw).length,'eligible long field batch was not compacted');assert(wire.at(-1).changes[0].before==='a'&&wire.at(-1).changes[0].after===secondTitle,'wire projection did not retain original before and final after');
await engine.close();engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}});await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
assert(calls.length===2&&calls[1].method==='GET'&&calls[1].path.endsWith(frozen.id),'batch recovery sent a new request');
assert(engine.getSnapshot().runtime.ledger.requests.get(frozen.id).body===frozen.body&&engine.getSnapshot().history.undo.length===2,'batch changed frozen bytes or collapsed history units');
for(const id of frozen.operationIds)assert(engine.getSnapshot().runtime.ledger.operations.get(id).phase==='resolved','batch ACK omitted a logical operation');
assert(engine.editor.history.undo().status==='applied','second edit undo failed');await engine.settle();assert(server.elements.get('a').data.title==='first','batch undo lost the intermediate historical value');
assert(engine.editor.history.undo().status==='applied','second edit undo failed');await engine.settle();assert(server.elements.get('a').data.title===firstTitle,'batch undo lost the intermediate historical value');
assert(engine.editor.history.undo().status==='applied','first edit undo failed');await engine.settle();assert(server.elements.get('a').data.title==='a','batch first inverse failed');
assert(engine.editor.history.redo().status==='applied','first edit redo failed');await engine.settle();assert(server.elements.get('a').data.title==='first','batch redo skipped an independent history unit');
assert(engine.editor.history.redo().status==='applied','first edit redo failed');await engine.settle();assert(server.elements.get('a').data.title===firstTitle,'batch redo skipped an independent history unit');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
+14 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createDocument, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
import { createDocument, createRegistry, builtinComponents, applyChanges } from '../../src/lib/canvas-core/index.ts';
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
import { createFoundryPersistence, createFoundryTransport, loadFoundryModel } from '../../src/lib/canvas-service/foundry.ts';
@@ -139,3 +139,16 @@ test('ordered batch freezes every logical identity, conditional change and parti
assert.throws(()=>port.compileBatch([second,intent],requestId,snapshot),/invalid_operation_order/);
assert.throws(()=>port.compileBatch([intent,intent],requestId,snapshot),/operation_interpreter_unavailable/);
});
test('Foundry compiles the proven projection without changing logical intents or receipt coverage',async()=>{
const middle='temporary '.repeat(100),secondId='90000000-0000-4000-8000-000000000003';
const first={...intent,changes:[{...intent.changes[0],after:middle}]},second={...intent,id:secondId,sequence:2,dependencies:[first.id],changes:[{...intent.changes[0],before:middle,after:'final'}]},ledger=createLedger();
ledger.add(first,{a:'exists'});ledger.add(second,{a:'exists'});
const port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:async()=>({status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[first.id,second.id],model:chunk(false)}})});
const compiled=port.compileBatch([first,second],requestId,{...snapshot,runtime:{...snapshot.runtime,ledger:ledger.getSnapshot()}}),request=ledger.freeze({...compiled,id:requestId,operationIds:[first.id,second.id]}),wire=JSON.parse(request.body).operations;
assert.ok(JSON.stringify(wire).length<JSON.stringify([first,second].map(({id,changes})=>({id,changes}))).length);
assert.equal(applyChanges(document,wire.flatMap(op=>op.changes)).elements.get('a').data.title,'final');
assert.equal(ledger.getSnapshot().operations.get(first.id).intent.changes[0].after,middle);
assert.deepEqual(wire.map(op=>op.id),request.operationIds);assert.equal((await port.submit(request,new AbortController().signal)).outcome,'applied');
});