Certify loaded canvas scopes and publish completeness with runtime queries

This commit is contained in:
2026-11-09 03:42:08 +08:00
parent ef08222915
commit cabf2ec667
26 changed files with 209 additions and 48 deletions
+10 -7
View File
@@ -679,22 +679,23 @@ async function engineRecoveryCase(name, assert) {
} finally { await engine?.close(); storage.close(); indexedDB.deleteDatabase(`${name}-engine`); }
}
async function loadingRecoveryCase(name,assert){
const root=crypto.randomUUID(),scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:root},registry=createRegistry(builtinComponents()),node=id=>({id,incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:120,height:80,rotation:0}}),a=node('A'),b=node('B'),canvases=[{id:root,parent:null}],prefix=createDocument(root,canvases,[a]),full=createDocument(root,canvases,[a,b]),versions=new Map([[root,1]]),loading={version:1,sourceToken:'browser-captured-read',declaredCanvasIds:[root],receivedElements:1,totalElements:2};
async function loadingRecoveryCase(name,assert,coverage=false){
const format=coverage?8:7;name+=coverage?"-coverage":"-legacy";
const root=crypto.randomUUID(),scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:root},registry=createRegistry(builtinComponents()),node=id=>({id,incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:120,height:80,rotation:0}}),a=node('A'),b=node('B'),canvases=[{id:root,parent:null}],prefix=createDocument(root,canvases,[a]),full=createDocument(root,canvases,[a,b]),versions=new Map([[root,1]]),loading={...(coverage?{version:2,canvasElementCounts:{[root]:2}}:{version:1}),sourceToken:'browser-captured-read',declaredCanvasIds:[root],receivedElements:1,totalElements:2};
const adapter=createBrowserStorage({name:`${name}-loading`,checkpointEvery:3});let ownerForTest;const storage={...adapter,async claim(scope){ownerForTest=await adapter.claim(scope);return ownerForTest;}};const calls=[],receipts=new Map();let server=full,version=1,engine;
const model=(complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases,elements:[...server.elements.values()],tombstones:{},versions:{[root]:version},removedCanvasIds:[]});
const transport=async input=>{if(input.method==='GET')return{status:200,body:{model:model()}};calls.push(input);const body=JSON.parse(input.body);assert(body.expected_versions[root]===version,'loading writer used a stale version');server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));version++;const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)};receipts.set(input.requestId,receipt);return{status:200,body:receipt};};
const persistence=createFoundryPersistence({registry,workspaceId:scope.workspaceId,rootId:root,transport}),open=()=>openProjectEngine({scope,registry,document:prefix,versions,loading,storage,persistence,createId:()=>crypto.randomUUID()});
try{engine=await open();const target={id:'A',incarnation:1};assert(engine.editor.dispatch({type:'element.data',target,patch:{text:'local before tail'}}).status==='applied','loading field rejected');await engine.settle();assert(calls.length===0&&engine.getSnapshot().runtime.ledger.requests.size===0,'loading prefix froze or sent a request');await engine.close();
const owner=await storage.claim(scope),image=await storage.recover(owner);assert(image.formatVersion===7,'loading state did not require recovery v7');decodeRecovery(image,registry);await owner.release();engine=await open();assert(engine.getSnapshot().document.elements.get('A').data.text==='local before tail','loading edit lost after IndexedDB reopen');assert(engine.editor.history.undo().status==='applied'&&engine.editor.history.redo().status==='applied','loading history did not reopen');await engine.settle();assert(calls.length===0,'history traversal escaped the loading hold');
const owner=await storage.claim(scope),image=await storage.recover(owner);assert(image.formatVersion===format,'loading state did not require its versioned recovery envelope');decodeRecovery(image,registry);await owner.release();engine=await open();assert(engine.getSnapshot().document.elements.get('A').data.text==='local before tail','loading edit lost after IndexedDB reopen');assert(engine.editor.history.undo().status==='applied'&&engine.editor.history.redo().status==='applied','loading history did not reopen');await engine.settle();assert(calls.length===0,'history traversal escaped the loading hold');
const before=engine.getDurability().durableRootRevision,nativePut=IDBObjectStore.prototype.put;let injected=false,failure;
IDBObjectStore.prototype.put=function(value,key){const request=nativePut.call(this,value,key);if(!injected&&this.name==='checkpoints'&&value?.image?.formatVersion!==7){injected=true;throw Error('loading_full_cut_abort');}return request;};
IDBObjectStore.prototype.put=function(value,key){const request=nativePut.call(this,value,key);if(!injected&&this.name==='checkpoints'&&value?.image?.formatVersion!==format){injected=true;throw Error('loading_full_cut_abort');}return request;};
try{await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.put=nativePut;}
assert(injected&&failure==='loading_full_cut_abort','completion did not reach forced checkpoint rollback');const partial=await adapter.recover(ownerForTest);assert(partial.rootRevision===before&&partial.formatVersion===7&&calls.length===0,'aborted full checkpoint released loading writes or lost the partial cut');
assert(injected&&failure==='loading_full_cut_abort','completion did not reach forced checkpoint rollback');const partial=await adapter.recover(ownerForTest);assert(partial.rootRevision===before&&partial.formatVersion===format&&calls.length===0,'aborted full checkpoint released loading writes or lost the partial cut');
await engine.persist();await engine.settle();
assert(!engine.loading.getState()&&server.elements.get('A').data.text==='local before tail'&&engine.getSnapshot().document.elements.size===2,'full tail overwrote the local prefix');await engine.close();engine=await open();await engine.settle();assert(!engine.loading.getState()&&engine.getSnapshot().document.elements.size===2,'completed recovery reopened as partial');
const database=await new Promise((resolve,reject)=>{const request=indexedDB.open(`${name}-loading`,2);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
try{const transaction=database.transaction(['checkpoints','journal'],'readonly'),read=store=>new Promise((resolve,reject)=>{const request=transaction.objectStore(store).getAll();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const cuts=(await Promise.all([read('checkpoints'),read('journal')])).flat();assert(cuts.every(cut=>(cut.image?.formatVersion??cut.header?.formatVersion)<7),'completed storage still requires a v7 journal base');}finally{database.close();}
try{const transaction=database.transaction(['checkpoints','journal'],'readonly'),read=store=>new Promise((resolve,reject)=>{const request=transaction.objectStore(store).getAll();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const cuts=(await Promise.all([read('checkpoints'),read('journal')])).flat();assert(cuts.every(cut=>(cut.image?.formatVersion??cut.header?.formatVersion)<7),'completed storage still requires a partial journal base');}finally{database.close();}
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-loading`);}
}
@@ -719,7 +720,7 @@ export async function run() {
await storage.commit(leader, { image: image(1) });
await rejects(() => storage.commit(leader, { image: { ...image(1), payload: null } }), 'storage_revision_conflict');
await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_revision_gap');
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 8 } }), 'unsupported_recovery_version');
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 9 } }), 'unsupported_recovery_version');
results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject');
// The first put succeeds before a later duplicate resource aborts the whole transaction.
@@ -770,6 +771,8 @@ export async function run() {
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
await loadingRecoveryCase(name,assert);
results.push('recovery v7 preserves loaded-field edits/history in IndexedDB; an injected full-checkpoint failure retains the partial cut and zero writes, then retry removes v7 journal dependencies before releasing requests');
await loadingRecoveryCase(name,assert,true);
results.push('recovery v8 preserves certified scope coverage and loading edits; full-checkpoint fault rolls back atomically, retry retires all partial journal dependencies before sending');
await scopedNetworkLoaderCase(assert);
results.push('scoped loader shares one real streamed fetch, exposes the first chunk before tail release, keeps the other subscriber alive after cancellation and preserves partial data after a malformed tail');
await defaultImageSizeCase(name, assert);
+36
View File
@@ -117,3 +117,39 @@ test('Workspace reports a background source failure while preserving the pending
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture(),reports=[];let current=true;const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>current,onError:error=>reports.push(error),adapters:()=>({persistence:{...f.persistence,async read(){throw Error('offline');}}})});
try{const engine=await workspace.openPartial(prefix,versions,state);engine.editor.dispatch({type:'element.data',target,patch:{text:'held while offline'}});await engine.persist();await assert.rejects(workspace.resumeLoading(root),/offline/);assert.equal(reports.length,1);assert.ok(engine.loading.getState());assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,7);current=false;await assert.rejects(workspace.resumeLoading(root),/identity_changed/);assert.equal(reports.length,1);}finally{await workspace.close();}
});
test('certified scopes distinguish a complete child from an incomplete parent and publish coverage atomically',async()=>{
const loading={...state,version:2,canvasElementCounts:{[root]:4,[child]:1}},f=fixture();let engine=await f.open({loading});
try{
const first=engine.editor.getQueries();assert.equal(first.lookupElement('B').status,'unloaded');assert.equal(first.lookupElement('A').status,'present');assert.equal(first.membersResult(root).complete,false);assert.equal(first.hitNodes(root,{x:50,y:40}).complete,false);
const next=createDocument(root,canvases,[a,frame,c]);assert.equal(engine.loading.accept(next,versions,{...loading,receivedElements:3}).status,'applied');
const queries=engine.editor.getQueries();assert.equal(queries.membersResult(child).complete,true);assert.deepEqual(queries.membersResult(child).ids,['C']);assert.equal(queries.membersResult(root).complete,false);assert.equal(queries.childCanvasesResult(root).complete,true);assert.equal(queries.incidentResult('C').complete,false);assert.equal(queries.relations.referenceComplete,false);assert.equal(queries.coverage.projectComplete,false);assert.equal(first.lookupElement('C').status,'unloaded');
engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'child edit'}});await engine.persist();assert.equal(f.storage.image().formatVersion,8);assert.equal(f.calls.length,0);decodeRecovery(f.storage.image(),registry);
const source=f.storage.image();for(const formatVersion of [1,2,3,4,5,6,7])assert.throws(()=>decodeRecovery({...source,formatVersion},registry),{code:'unsupported_recovery_version'});
for(const mutate of [image=>delete image.payload.runtime.loading.canvasElementCounts,image=>image.payload.runtime.loading.version=1,image=>image.payload.runtime.loading.version=99,image=>image.payload.runtime.loading.canvasElementCounts[child]=0]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
await engine.close();engine=await f.open({loading});assert.equal(engine.editor.getQueries().membersResult(child).complete,true);assert.equal(engine.getSnapshot().document.elements.get('C').data.text,'child edit');
assert.equal(engine.loading.accept(full,versions,{...loading,receivedElements:5}).status,'applied');const before=engine.getSnapshot(),oldQueries=engine.editor.getQueries();assert.equal(oldQueries.lookupElement('missing').status,'unloaded');assert.equal(oldQueries.membersResult(root).complete,true);
assert.equal((await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken})).status,'applied');assert.equal(engine.getSnapshot().revision,before.revision,'coverage-only completion must not invent a document revision');assert.equal(engine.editor.getQueries().lookupElement('missing').status,'absent');assert.equal(engine.editor.getQueries().coverage.projectComplete,true);assert.equal(oldQueries.lookupElement('missing').status,'unloaded');await engine.settle();assert.equal(f.server().elements.get('C').data.text,'child edit');assert.equal(f.storage.image().formatVersion<7,true);
}finally{await engine.close();}
});
test('legacy prefixes do not infer scope completion; a changed count manifest cannot replace the published root',async()=>{
const f=fixture(),engine=await f.open();try{
assert.equal(engine.loading.accept(full,versions,{...state,receivedElements:5}).status,'applied');assert.equal(engine.editor.getQueries().membersResult(child).complete,false);
const before=engine.getSnapshot();assert.equal(engine.loading.accept(full,versions,{...state,version:2,canvasElementCounts:{[root]:4,[child]:1},receivedElements:5}).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before);
}finally{await engine.close();}
});
test('zero-member scopes are complete only once their entry dependencies publish',async()=>{
const f=fixture(),document=createDocument(root,canvases,[a,frame]),loading={...state,version:2,totalElements:2,receivedElements:2,canvasElementCounts:{[root]:2,[child]:0}},engine=await f.open({document,loading});
try{assert.deepEqual(engine.editor.getQueries().membersResult(child),{ids:[],complete:true});assert.equal(engine.editor.getQueries().lookupElement('not-here').status,'unloaded');}finally{await engine.close();}
});
test('v2 admits path edits in a certified connection owner while another scope is incomplete, preserving undo and v8 recovery',async()=>{
const f=fixture(),loading={...state,version:2,receivedElements:5,totalElements:6,canvasElementCounts:{[root]:4,[child]:2}},engine=await f.open({document:full,loading}),path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:25},in:{x:0,y:0},out:{x:0,y:0}}]};
try{
assert.equal(engine.editor.getQueries().membersResult(root).complete,true);assert.equal(engine.editor.getQueries().membersResult(child).complete,false);
const command={type:'connection.path',target:{id:'E',incarnation:1},path};assert.equal(engine.editor.can(command).enabled,true);assert.equal(engine.editor.dispatch(command).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('E').path.knots[0].position.x,50);
assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('E').path,null);assert.equal(engine.editor.history.redo().status,'applied');await engine.persist();assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,8);assert.equal(decodeRecovery(f.storage.image(),registry).document.elements.get('E').path.knots[0].position.x,50);
const before=engine.getSnapshot();assert.equal(engine.loading.accept(full,versions,{...loading,canvasElementCounts:{[root]:5,[child]:1}}).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before);
}finally{await engine.close();}
const other=fixture(),incomplete=await other.open({document:full,loading:{...loading,canvasElementCounts:{[root]:5,[child]:1}}});try{assert.equal(incomplete.editor.can({type:'connection.path',target:{id:'E',incarnation:1},path}).enabled,false);}finally{await incomplete.close();}
});
+7
View File
@@ -42,3 +42,10 @@ test('owned preview records do not freeze or retain mutable caller payloads; fin
test('published identities cannot be repeated in later chunks and invalid input closes the projection',()=>{
const preview=createModelPreview(registry);preview.update({...head(2),canvases:[{id:root,parent:null}]});preview.update(delta([node('one')],1,2));assert.throws(()=>preview.update(delta([node('one')],2,2)));assert.throws(()=>preview.update(delta([node('two')],2,2)),{code:'model_preview_closed'});
});
test('scope certificates count received records without treating dependency-blocked members as published',()=>{
const preview=createModelPreview(registry);const counts={[root]:3,[child]:1};preview.update({...head(),canvasElementCounts:counts});counts[child]=0;
const waiting=preview.update(delta([a,edge],2));assert.equal(waiting.document.canvases.has(child),false);assert.equal(waiting.canvasElementCounts[child],1);assert.equal(waiting.waitingElements,2);
const shown=preview.update(delta([frame,b],4));assert.equal(shown.document.elements.size,4);assert.deepEqual(shown.canvasElementCounts,{[root]:3,[child]:1});
const invalid=createModelPreview(registry),first=invalid.update({...head(1),canvasElementCounts:{[root]:0,[child]:1}});assert.throws(()=>invalid.update(delta([b],1,1)),{code:'invalid_model_coverage'});assert.strictEqual(invalid.getSnapshot(),first);
});
+6
View File
@@ -54,3 +54,9 @@ test('stream transport fences changed identity and abort cancels a blocked body
const reply=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1',signal:abort.signal});const iterator=reply.stream[Symbol.asyncIterator]();const pending=iterator.next();await Promise.resolve();abort.abort();await assert.rejects(pending);assert.equal(cancelled,1);
const other=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1'});current=false;await assert.rejects(()=>other.stream[Symbol.asyncIterator]().next(),{code:'identity_changed'});assert.equal(cancelled,2);
});
test('optional snapshot scope counts preserve legacy streams and reject invalid or exceeded coverage before publication',async()=>{
const rows=records();rows[0].canvasElementCounts={[root]:1};const seen=await collect(decodeModelStream(source(rows),root,registry,signal()));assert.deepEqual(seen[0].value.canvasElementCounts,{[root]:1});assert.equal(Object.isFrozen(seen[0].value.canvasElementCounts),true);
for(const manifest of [null,[],{}, {[root]:-1},{[root]:.5},{[root]:2},{[root]:1,[workspace]:0}]){const broken=records();broken[0].canvasElementCounts=manifest;await assert.rejects(()=>collect(decodeModelStream(source(broken),root,registry,signal())),{code:'invalid_model_coverage'});}
const broken=records();broken[0].canvases.push({id:workspace,parent:{canvasId:root,entryElementId:'frame'}});broken[0].versions[workspace]=1;broken[0].canvasElementCounts={[root]:0,[workspace]:1};const iterator=decodeModelStream(source(broken),root,registry,signal())[Symbol.asyncIterator]();assert.equal((await iterator.next()).value.value.receivedElements,0);await assert.rejects(iterator.next(),{code:'invalid_model_coverage'});
});
+7
View File
@@ -47,6 +47,13 @@ try {
const accepted=engine.loading.accept(document,versions,state);
return accepted.status==='applied'?engine.loading.complete(document,versions,{sourceToken:state.sourceToken}):accepted;
}
export const certified:LoadingState={version:2,sourceToken:'consumer-read',declaredCanvasIds:['root'],receivedElements:0,totalElements:1,canvasElementCounts:{root:1}};
export function inspectPartial(engine:Awaited<ReturnType<typeof openProjectEngine>>){
const queries=engine.editor.getQueries(),lookup=queries.lookupElement('node');
const id:string|undefined=lookup.status==='present'?lookup.element.id:undefined;
const complete:boolean=queries.membersResult(engine.getSnapshot().document.projectId).complete;
return {id,complete,projectComplete:queries.coverage.projectComplete};
}
export function open(scope:StorageScope,storage:StoragePort,transport:ServiceTransport){
return openProjectEngine({scope,storage,registry,document:createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]),versions:new Map([[scope.projectId,1]]),persistence:createFoundryPersistence({registry,transport,rootId:scope.projectId,workspaceId:scope.workspaceId})});
}
+7
View File
@@ -39,3 +39,10 @@ test('reverse element/canvas references remove obsolete subjects and mark malfor
const custom=createRegistry([...builtinComponents(),{id:'refs',dataVersion:1,structure:'node',validate:()=>true,features:[],copy:{references:[{path:['targets'],many:true,target:'element',external:'preserve'},{path:['scopes'],many:true,target:'canvas',external:'preserve'}],omit:[]}}]),r={...node('R','P','refs'),data:{targets:['A','A'],scopes:['S']}},r2={...node('R2','P','refs'),data:{targets:['B'],scopes:['S']}},doc=createDocument('project',[...document.canvases.values()],[...document.elements.values(),r,r2]),q=createDocumentQueries(doc,custom);
assert.deepEqual([...q.relations.canvasReferrers.get('S')],['R','R2']);assert.deepEqual([...q.relations.elementReferrers.get('A')],['F','R']);const change={kind:'field',target:{id:'R',incarnation:1},field:'data.scopes',before:['S'],after:['P']},next=applyChanges(doc,[change]),updated=advanceDocumentQueries(q,next,custom,1,[change]);assert.deepEqual([...updated.relations.canvasReferrers.get('S')],['R2']);assert.deepEqual([...updated.relations.canvasReferrers.get('P')],['R']);assert.equal(updated.relations.elementReferrers,q.relations.elementReferrers);const invalid={kind:'field',target:{id:'R',incarnation:1},field:'data.targets',before:['A','A'],after:'invalid'},bad=advanceDocumentQueries(updated,applyChanges(next,[invalid]),custom,2,[invalid]);assert.equal(bad.relations.referenceComplete,false);assert.deepEqual([...bad.relations.opaqueReferences],['R']);assert.equal(bad.relations.canvasReferrers.has('P'),false);assert.deepEqual([...bad.relations.elementReferrers.get('A')],['F']);
});
test('coverage changes share the metadata publication boundary and invalid coverage cannot partially publish a root',()=>{
const editor=createEditor({registry,document,runtime:{complete:false},queryCoverage:snapshot=>({projectComplete:snapshot.runtime.complete,topologyComplete:true,completeCanvasIds:snapshot.runtime.complete?['P','S']:[]})});
const before=editor.getSnapshot(),queries=editor.getQueries();assert.equal(queries.lookupElement('unknown').status,'unloaded');assert.equal(queries.membersResult('P').complete,false);let observed;
editor.subscribe((_commit,snapshot)=>observed={root:snapshot,queries:editor.getQueries()});assert.equal(editor.updateRuntime(()=>({complete:true})).status,'applied');assert.equal(observed.root,editor.getSnapshot());assert.equal(observed.queries.lookupElement('unknown').status,'absent');assert.equal(observed.root.revision,before.revision);assert.equal(queries.lookupElement('unknown').status,'unloaded');
const invalid=createEditor({registry,document,runtime:{bad:false},queryCoverage:snapshot=>({projectComplete:false,topologyComplete:false,completeCanvasIds:snapshot.runtime.bad?['foreign']:[]})}),root=invalid.getSnapshot(),query=invalid.getQueries();assert.equal(invalid.updateRuntime(()=>({bad:true})).code,'invalid_query_coverage');assert.strictEqual(invalid.getSnapshot(),root);assert.strictEqual(invalid.getQueries(),query);
});
+1 -1
View File
@@ -24,5 +24,5 @@ test('cached subtree height cannot bypass the depth bound and omitted object fie
const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n}));
});
test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.throws(()=>sealRecoveryImage(8,1,1,{}),/corrupt_recovery_image/);
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(7,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(8,1,1,{})),true);assert.throws(()=>sealRecoveryImage(9,1,1,{}),/corrupt_recovery_image/);
});
+1 -1
View File
@@ -45,7 +45,7 @@ const upstream = createServer(async (request, response) => {
const value=model(documents.get(read[1])),token='fixture-snapshot-'+calls.length;let sequence=0;
response.writeHead(200,{'Content-Type':'application/vnd.foundry.canvas-model+ndjson;version=1'});
const line=record=>response.write(JSON.stringify({streamVersion:1,snapshotToken:token,sequence:sequence++,...record})+'\n');
line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length});
line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length,canvasElementCounts:Object.fromEntries(value.canvases.map(canvas=>[canvas.id,value.elements.filter(element=>element.ownerCanvasId===canvas.id).length]))});
if(read[1]===brokenStreamRoot){line({kind:'elements',elements:value.elements.slice(0,1)});response.end('{broken tail}\n');return;}
let start=0;
if(read[1]===abandonedRoot){line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await abandonedGate;if(response.destroyed)return;}