feat(canvas): compact mutable batches with conditional wire projections
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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})));
|
||||
});
|
||||
@@ -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);}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user