diff --git a/src/lib/canvas-runtime/batch-projection.ts b/src/lib/canvas-runtime/batch-projection.ts new file mode 100644 index 0000000..da9790e --- /dev/null +++ b/src/lib/canvas-runtime/batch-projection.ts @@ -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 = (a: ReadonlyMap, b: ReadonlyMap) => 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(); + const canvases = new Map(acknowledged.canvases.has(acknowledged.projectId) ? [[acknowledged.projectId, acknowledged.canvases.get(acknowledged.projectId)!]] : []); + const tombstones = new Map(), seen = new Set(); + 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); +} diff --git a/src/lib/canvas-runtime/ledger.ts b/src/lib/canvas-runtime/ledger.ts index 2229ebe..a3bad04 100644 --- a/src/lib/canvas-runtime/ledger.ts +++ b/src/lib/canvas-runtime/ledger.ts @@ -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; readonly requests: ReadonlyMap }; /** 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(); 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) { diff --git a/src/lib/canvas-service/foundry.ts b/src/lib/canvas-service/foundry.ts index 9cd0215..d96a842 100644 --- a/src/lib/canvas-service/foundry.ts +++ b/src/lib/canvas-service/foundry.ts @@ -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 }); } diff --git a/tests/headless/batch-projection.test.mjs b/tests/headless/batch-projection.test.mjs new file mode 100644 index 0000000..72cd1db --- /dev/null +++ b/tests/headless/batch-projection.test.mjs @@ -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({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}))); +}); diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index a3e2560..2368d3f 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -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).lengthDate.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);} } diff --git a/tests/headless/service.test.mjs b/tests/headless/service.test.mjs index 85c458c..23483d5 100644 --- a/tests/headless/service.test.mjs +++ b/tests/headless/service.test.mjs @@ -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({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'); +});