docs(tests): explain asset adoption retention and depot storage
This commit is contained in:
@@ -1,23 +1,37 @@
|
||||
/**
|
||||
* 供浏览器存储 runner 调用的真实 IndexedDB 接纳场景,不是 node:test 自动收集的文件。
|
||||
* 先由源项目写入共享字节,再从目标项目授权复用,重开后验证 binding、显示、历史及关闭项目的持久化 pin。
|
||||
*/
|
||||
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
||||
import {createWorkspaceRuntime} from '../../src/lib/canvas-runtime/workspace.ts';
|
||||
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||
// 每次使用独立数据库;assert 由浏览器 runner 注入,finally 释放 leader/runtime 并请求删库。
|
||||
export async function assetAdoptionStorageCase(assert){
|
||||
const name=`adoption-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'source'},authority='https://assets.test/w',bytes=new Blob(['shared original'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),ref={version:1,authority,digest,asset:{id:'resource',url:`${authority}/resource`,width:2,height:1,bytes:bytes.size,mime_type:'image/png'}},registry=createRegistry(builtinComponents());
|
||||
let storage=createBrowserStorage({name}),leader=await storage.claim(scope),runtime,engine,allowed=true,authorizations=0,reads=0,submits=0,version=1,document=createDocument('target',[{id:'canvas',parent:null}],[]);const receipts=new Map();
|
||||
// 简化源项目 image 与 depot cut;目标引擎会自行生成完整的可恢复状态根。
|
||||
const image=revision=>({formatVersion:1,rootRevision:revision,localRevision:revision,payload:{source:revision}}),cut=pins=>({version:1,authority,complete:true,pins,writes:[]});
|
||||
// 源字节已经预热,authority 仍授权;任何再次下载或进入上传流程的调用都直接报错。
|
||||
const assets={authority:{scope:authority,async resolve(){authorizations++;if(!allowed)throw Error('denied');return ref;},async read(){reads++;throw Error('cache miss should not download');}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/assets/${id}`,async upload(){throw Error('existing bytes uploaded');},async query(){throw Error('upload queried');},accept(){throw Error('upload accepted');}};
|
||||
// 简化目标文档服务:提交应用变化并保存 canonical 回执,版本逐次递增。
|
||||
const persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){submits++;document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
|
||||
// 重新装配 WorkspaceRuntime 并打开目标项目;用于证明数据来自新存储连接的恢复,而非旧内存状态。
|
||||
const open=async()=>{runtime=createWorkspaceRuntime({accountId:'a',workspaceId:'w',authEpoch:1,registry,storage,isCurrentIdentity:()=>true,adapters:()=>({assets,persistence})});engine=await runtime.open(createDocument('target',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));};
|
||||
// 在接纳资源的同步事务内创建 image 元素,使 binding 和内容一起发布。
|
||||
const adopt=id=>engine.adoptResource('resource',(handle,tx)=>tx.dispatch({type:'element.create',element:{id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}}}));
|
||||
try{
|
||||
// 预写源项目共享字节和引用后释放源 leader,目标接纳必须从共享 depot 获取而不复制上传。
|
||||
await storage.commit(leader,{image:image(0),depot:{...cut([['resource',['element:source']]]),writes:[{ref,bytes}]}});await leader.release();leader=undefined;await open();
|
||||
assert((await adopt('picture')).status==='applied','adoption did not publish');await engine.settle();assert(authorizations===1&&reads===0&&submits===1,'adoption bypassed authorization, downloaded warm bytes or submitted twice');assert(engine.getSnapshot().runtime.resources.size===0&&engine.getSnapshot().runtime.assetBindings.size===1,'adoption invented upload metadata');
|
||||
// 关闭整个 runtime 再创建新 storage,验证目标 binding 与原始字节已经持久化。
|
||||
await runtime.close();runtime=undefined;storage=createBrowserStorage({name});await open();
|
||||
const display=await engine.displayResource('resource');assert(display.url.startsWith('blob:')&&await(await fetch(display.url)).text()==='shared original','reopening lost durable binding or shared bytes');display.release();
|
||||
// 已有缓存也不能绕过撤权;失败必须维持同一状态根,随后重新允许以继续历史检查。
|
||||
allowed=false;const before=engine.getSnapshot();try{await adopt('denied');throw Error('revoked cache adoption succeeded');}catch(error){assert(error.message==='denied','unexpected authorization failure');}assert(engine.getSnapshot()===before,'denied authorization changed Root');allowed=true;
|
||||
assert(engine.editor.history.undo().status==='applied','adopt undo failed');await engine.settle();assert(engine.editor.history.redo().status==='applied','adopt redo failed');await engine.settle();assert(engine.getSnapshot().document.elements.get('picture').incarnation===2,'redo did not create a fresh incarnation');
|
||||
// Close every editor. GC must use target's durable manifest rather than an open engine map.
|
||||
await engine.close();leader=await storage.claim(scope);await storage.commit(leader,{image:image(1),depot:cut([])});const collected=await storage.assetDepot.collect(leader,{authority,now:100,retentionMs:0,limit:128});assert(collected.complete&&collected.pinnedIds.includes('resource')&&!collected.purgedIds.length,'closed destination pins did not protect shared bytes');
|
||||
// 直接扫描已关闭目标的项目文件表,确认共享资源没有被复制成目标项目本地文件。
|
||||
const targetLeader=await storage.claim({...scope,projectId:'target'});try{const page=await storage.scanFiles(targetLeader,{expectedRootRevision:(await storage.recover(targetLeader)).rootRevision,limit:128});assert(page.files.length===0,'destination duplicated shared bytes into project files');}finally{await targetLeader.release();}
|
||||
await leader.release();leader=undefined;await runtime.close();runtime=undefined;
|
||||
}finally{await leader?.release();await runtime?.close();storage.close();indexedDB.deleteDatabase(name);}
|
||||
|
||||
@@ -1,22 +1,65 @@
|
||||
/**
|
||||
* 已有资源接纳(adoption)与文档图片导入的引擎测试。复用原始字节仍需重新授权,不生成多余上传任务。
|
||||
* 绑定、文档、history、ledger 和导入进度通过一致状态根发布,资源字节及引用通过相应持久化 cut 保存。
|
||||
* 内存端口可控制授权、配额和提交时机;真实 IndexedDB 的事务/回收竞态由 browser-cases 另测。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRegistry,builtinComponents,createDocument,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import {createWorkspaceRuntime} from '../../src/lib/canvas-runtime/workspace.ts';
|
||||
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
// 共享内置定义及账户/工作区作用域;authority 标识原始资源的授权来源。
|
||||
const registry=createRegistry(builtinComponents()),authority='https://source.test/w/assets',accountId='account',workspaceId='workspace';
|
||||
// 计算真实 Blob 的 SHA-256;此处 MIME 为测试元数据,不试图解码文本字节为图片像素。
|
||||
const bytes=new Blob(['original bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');
|
||||
// 不可变资源引用同时包含 authority、digest 和服务端资源元数据,不能只以 URL 代表身份。
|
||||
const ref={version:1,authority,digest,asset:{id:'shared',url:`${authority}/shared`,bytes:bytes.size,width:2,height:1,mime_type:'image/png'}};
|
||||
// 使用接纳句柄创建 image 元素;assetId 保存资源身份,src 使用经授权的来源。
|
||||
const node=(handle,id='picture',owner='canvas')=>({id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}});
|
||||
// 为各项目维护独立文档/回执/leader,同时在同账户工作区内共享 depot 字节;开关可阻塞授权或模拟配额失败。
|
||||
function fixture(){
|
||||
const sourceFiles=new Map(),roots=new Map(),cache=new Map(),leaders=new Map(),servers=new Map(),calls=[],batches=[],errors=[];let now=0,next=0,allowed=true,gate,fail=false,closed=false;
|
||||
// 共享缓存键含账户、工作区及 authority+assetId;项目 ID 不进入键,便于跨项目复用。
|
||||
const key=(leader,id)=>JSON.stringify([leader.scope.accountId,leader.scope.workspaceId,id]);
|
||||
const storage={assetDepot:{async read(leader,auth,id){assert.equal(leader.active,true);return cache.get(key(leader,auth+id));},async collect(){return{complete:false,blockedProjects:['fixture']};}},async claim(scope){assert.equal(leaders.has(scope.projectId),false);const leader={scope,fence:1,active:true,async release(){leader.active=false;leaders.delete(scope.projectId);}};leaders.set(scope.projectId,leader);return leader;},async recover(leader){return structuredClone(roots.get(leader.scope.projectId)??null);},async readFile(_leader,id){return sourceFiles.get(id);},async commit(leader,batch){if(fail)throw Error('quota');assert.equal(leader.active,true);for(const file of batch.files??[])sourceFiles.set(file.id,file.bytes);const root=structuredClone(batch.image);for(const write of batch.depot?.writes??[]){const k=key(leader,write.ref.authority+write.ref.asset.id),previous=cache.get(k);cache.set(k,{ref:write.ref,bytes:write.bytes??previous?.bytes});}roots.set(leader.scope.projectId,root);batches.push(batch);return{durableRootRevision:root.rootRevision};},close(){closed=true;}};
|
||||
const assets={authority:{scope:authority,async resolve(id,signal){calls.push('authorize');if(gate)await gate;if(!allowed)throw Error('denied');signal.throwIfAborted();return{...ref,asset:{...ref.asset,id,url:`${authority}/${id}`}};},async read(){calls.push('read');return bytes;}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/remote/${id}`,async upload(){throw Error('adoption uploaded bytes');},async query(){throw Error('adoption queried upload');},accept(){throw Error('adoption accepted upload');}};
|
||||
// 最小存储端口:校验活动 leader,保存文件、image 及 depot writes;cache collect 故意不报告完整扫描。
|
||||
const storage={assetDepot:{
|
||||
// depot 读取只接受活动 leader,并按账户/工作区/authority/资源身份查询共享缓存。
|
||||
async read(leader,auth,id){assert.equal(leader.active,true);return cache.get(key(leader,auth+id));},
|
||||
// 此内存 fixture 不证明完整 GC 覆盖,明确返回 blockedProjects 以避免假装可删除。
|
||||
async collect(){return{complete:false,blockedProjects:['fixture']};}},
|
||||
// 每个项目只允许一个 leader;release 将其标记失效并从占用表删除。
|
||||
async claim(scope){assert.equal(leaders.has(scope.projectId),false);const leader={scope,fence:1,active:true,async release(){leader.active=false;leaders.delete(scope.projectId);}};leaders.set(scope.projectId,leader);return leader;},
|
||||
// 从项目 roots 表克隆上次 image,缺失时返回 null。
|
||||
async recover(leader){return structuredClone(roots.get(leader.scope.projectId)??null);},
|
||||
// 测试用项目源文件表;与 depot 的跨项目字节缓存分开。
|
||||
async readFile(_leader,id){return sourceFiles.get(id);},
|
||||
// 配额故障在任何写之前抛出;正常写入项目文件、共享字节及克隆 image,并记录提交批次供断言。
|
||||
async commit(leader,batch){if(fail)throw Error('quota');assert.equal(leader.active,true);for(const file of batch.files??[])sourceFiles.set(file.id,file.bytes);const root=structuredClone(batch.image);
|
||||
// 只有 ref 更新而未提供新字节时沿用旧字节;真实事务回滚能力由浏览器用例覆盖。
|
||||
for(const write of batch.depot?.writes??[]){const k=key(leader,write.ref.authority+write.ref.asset.id),previous=cache.get(k);cache.set(k,{ref:write.ref,bytes:write.bytes??previous?.bytes});}roots.set(leader.scope.projectId,root);batches.push(batch);return{durableRootRevision:root.rootRevision};},
|
||||
// 记录 storage 关闭,供 WorkspaceRuntime 关闭顺序用例检查。
|
||||
close(){closed=true;}};
|
||||
// 授权必须每次执行,字节读取可由共享缓存避免;上传相关方法一旦被误调用就报错。
|
||||
const assets={authority:{scope:authority,
|
||||
// 先记录授权尝试并等待可控 gate,再检查权限和 AbortSignal;返回当前资源 ID 对应的规范引用。
|
||||
async resolve(id,signal){calls.push('authorize');if(gate)await gate;if(!allowed)throw Error('denied');signal.throwIfAborted();return{...ref,asset:{...ref.asset,id,url:`${authority}/${id}`}};},
|
||||
// 原始字节仅在缓存未命中时读取,计数用于检测重复下载。
|
||||
async read(){calls.push('read');return bytes;}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/remote/${id}`,
|
||||
// 接纳既有资源不应创建上传;此断言端口一旦被调用就使测试失败。
|
||||
async upload(){throw Error('adoption uploaded bytes');},
|
||||
// 接纳也不应查询上传回执,否则说明错误进入上传状态机。
|
||||
async query(){throw Error('adoption queried upload');},
|
||||
// 接纳使用 authority 的资源证明,不能把它误当上传 accept 回执处理。
|
||||
accept(){throw Error('adoption accepted upload');}};
|
||||
// 每个项目的假服务各自保存文档和递增版本;提交记录 canonical 回执,供本地成功接纳使用。
|
||||
function adapters(projectId){if(!servers.has(projectId))servers.set(projectId,{document:createDocument(projectId,[{id:'canvas',parent:null}],[]),version:1,receipts:new Map()});const server=servers.get(projectId);return{assets,persistence:{compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){calls.push('submit');const intent=JSON.parse(request.body).intent;server.document=applyChanges(server.document,intent.changes);server.receipts.set(request.id,{document:server.document,versions:new Map([['canvas',++server.version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>server.receipts.get(request.id)}};}
|
||||
// 暴露时间、授权、持久化和挂起开关;open 可复用上次 image,但不复用旧引擎实例。
|
||||
return{advance(){now+=10000;},storage,roots,cache,errors,batches,calls,adapters,servers,get closed(){return closed;},deny(){allowed=false;},gate(value){gate=value;},fail(value){fail=value;},open(projectId='p',extra={}){return openProjectEngine({scope:{accountId,workspaceId,projectId},registry,document:createDocument(projectId,[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage,...adapters(projectId),clock:{now:()=>now,setTimer:()=>0,clearTimer(){}},createId:()=>`id-${++next}`,onError:e=>errors.push(e),...extra});}};
|
||||
}
|
||||
// 把接纳和创建图片放在同一个同步事务内,确保不会只留下孤立 binding。
|
||||
const adopt=(engine,id='picture')=>engine.adoptResource('shared',(handle,tx)=>tx.dispatch({type:'element.create',element:node(handle,id)}));
|
||||
// 一次接纳同时发布绑定及历史/账本,保存共享字节而不生成上传;重开可显示原字节,undo/redo 递增元素身份。
|
||||
test('adoption atomically publishes binding/history/ledger and reopens shared bytes without an upload',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{
|
||||
assert.equal((await adopt(engine)).status,'applied');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().runtime.resources.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings.get('shared').digest,digest);await engine.settle();
|
||||
@@ -25,12 +68,14 @@ test('adoption atomically publishes binding/history/ledger and reopens shared by
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(f.calls.filter(v=>v==='read').length,1);assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 第二个项目复用共享字节但再次授权;权限撤销后即使缓存仍在,也不能修改状态根。
|
||||
test('a second project uses shared bytes but must obtain fresh permission before creating a binding',async()=>{
|
||||
const f=fixture(),p=await f.open('p');await adopt(p);await p.settle();await p.close();let q=await f.open('q');try{
|
||||
assert.equal((await adopt(q)).status,'applied');await q.settle();assert.equal(f.calls.filter(v=>v==='authorize').length,2);assert.equal(f.calls.filter(v=>v==='read').length,1);await q.close();q=await f.open('q');const display=await q.displayResource('shared');assert.equal(await(await fetch(display.url)).text(),'original bytes');display.release();
|
||||
f.deny();const before=q.getSnapshot();await assert.rejects(adopt(q,'denied'),/denied/);assert.strictEqual(q.getSnapshot(),before);
|
||||
}finally{await q.close();}
|
||||
});
|
||||
// 重复 ID 导致事务拒绝、异步事务或空事务都不能留下半份 binding/history,后续落盘也不应写缓存。
|
||||
test('rejected and asynchronous transactions cannot leave a binding or history behind',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{
|
||||
const before=engine.getSnapshot();const rejected=await engine.adoptResource('shared',(handle,tx)=>{tx.dispatch({type:'element.create',element:node(handle)});tx.dispatch({type:'element.create',element:node(handle)});});assert.equal(rejected.status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
|
||||
@@ -38,24 +83,29 @@ test('rejected and asynchronous transactions cannot leave a binding or history b
|
||||
assert.equal((await engine.adoptResource('shared',()=>{})).operationIds.length,0);assert.strictEqual(engine.getSnapshot(),before);await engine.settle();assert.equal(f.cache.size,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// binding 只能由接纳路径管理;格式降级、操作/资源身份不符和伪造 digest 无法通过恢复,换 authority 也不能解释旧绑定。
|
||||
test('binding mutation is owned and recovery rejects downgraded, mismatched or forged bindings',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{await adopt(engine);await engine.settle();assert.equal(engine.editor.updateRuntime(previous=>({...previous,assetBindings:new Map()})).code,'asset_bindings_owned');
|
||||
for(const change of [image=>image.formatVersion=12,image=>image.payload.runtime.assetBindings[0][1].operationId='missing',image=>image.payload.runtime.assetBindings[0][1].asset.id='other',image=>image.payload.runtime.assetBindings[0][1].digest='bad']){const image=structuredClone(f.roots.get('p'));change(image);assert.throws(()=>decodeRecovery(image,registry));}
|
||||
await engine.close();await assert.rejects(f.open('p',{assets:{...f.adapters('p').assets,authority:{...f.adapters('p').assets.authority,scope:'other'}}}),{code:'asset_binding_interpreter_unavailable'});
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 配额失败保留本地可见图片,但阻止文档网络派发;修复后先使原子 cut 持久化,再允许发送一次文档请求。
|
||||
test('quota failure preserves the visible adopted image and blocks document dispatch until its atomic cut is durable',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{await engine.settle();f.fail(true);assert.equal((await adopt(engine)).status,'applied');await assert.rejects(engine.settle(),/quota/);assert.equal(f.calls.includes('submit'),false);assert.equal(f.cache.size,0);const display=await engine.displayResource('shared');assert.equal(await(await fetch(display.url)).text(),'original bytes');display.release();f.fail(false);await engine.retry();await engine.settle();f.advance();await engine.retry();await engine.settle();assert.equal(f.calls.filter(v=>v==='submit').length,1);assert.equal(f.cache.size,1);}finally{f.fail(false);await engine.close();}
|
||||
});
|
||||
// 授权仍挂起时关闭引擎,迟到授权结果不能发布内容、绑定或共享缓存,关闭过程仍能完成。
|
||||
test('scope retirement and close fence late adoption, retaining no half binding',async()=>{
|
||||
const f=fixture();let done;f.gate(new Promise(resolve=>done=resolve));const engine=await f.open(),pending=adopt(engine);await Promise.resolve();await Promise.resolve();const closing=engine.close();done();await assert.rejects(pending);await closing;assert.equal(f.roots.get('p').payload.document.elements.length,0);assert.equal(f.cache.size,0);
|
||||
});
|
||||
// WorkspaceRuntime 为多个项目提供同一个 depot;关闭项目后再关闭共享资源和存储,不能冻结调用者 storage 对象。
|
||||
test('WorkspaceRuntime owns one depot across projects and closes it after projects',async()=>{
|
||||
const f=fixture(),runtime=createWorkspaceRuntime({accountId,workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>true,adapters:id=>f.adapters(id)});
|
||||
assert.deepEqual(runtime.assetDepot.scope,{accountId,workspaceId});assert.equal(Object.isFrozen(f.storage),false);
|
||||
const p=await runtime.open(createDocument('p',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));await adopt(p);await p.settle();const q=await runtime.open(createDocument('q',[{id:'canvas',parent:null}],[]),new Map([['canvas',1]]));await adopt(q);await q.settle();assert.equal(runtime.assetDepot.getResources().size,1);assert.equal(f.calls.filter(v=>v==='read').length,1);await runtime.close();assert.equal(f.closed,true);assert.equal(runtime.assetDepot.getResources().size,0);
|
||||
});
|
||||
|
||||
// 旧的无引用状态根正在落盘时清除字节,再接纳新图片;新字节必须留到匹配的新 cut 持久化,不能被旧提交释放。
|
||||
test('an older Root cannot release newly adopted bytes while its unpinned manifest is committing',async()=>{
|
||||
const f=fixture(),engine=await f.open('p',{historyLimit:1});let release,started;const commit=f.storage.commit;
|
||||
try{
|
||||
@@ -67,9 +117,15 @@ test('an older Root cannot release newly adopted bytes while its unpinned manife
|
||||
f.cache.clear();assert.equal((await adopt(engine,'fresh')).status,'applied');release();await engine.settle();assert.equal(await f.cache.values().next().value.bytes.text(),'original bytes');assert.equal(f.roots.get('p').payload.document.elements.some(element=>element.id==='fresh'),true);
|
||||
}finally{release?.();f.storage.commit=commit;await engine.close();}
|
||||
});
|
||||
// 生成含图片引用的导入文件;其 src 故意不可信,导入应改用 authority 的经授权原始 URL。
|
||||
const exportedImages=(ids=['shared'])=>new Blob([JSON.stringify({documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:ids.map((id,index)=>node({id,src:'https://untrusted.test/ignored'},'imported-'+index,'source')),tombstones:{}})]);
|
||||
// 文档导入授权原字节,并以同一状态根发布绑定、内容和 committedSourceKeys;不上传,重开后仍可显示。
|
||||
test('document image import authorizes original bytes and atomically publishes binding, content and progress without upload, then reopens',async()=>{const f=fixture();let engine=await f.open();try{const observed=[],stop=engine.editor.subscribe((_,root)=>{if(root.document.elements.size){const job=[...root.runtime.imports.values()][0];observed.push(root);assert.ok(root.runtime.assetBindings.get('shared'));assert.equal(job.committedSourceKeys.length,root.document.elements.size);}}),id=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();stop();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.ok(observed.length);const image=[...engine.getSnapshot().document.elements.values()][0];assert.equal(image.data.src,ref.asset.url);assert.equal(image.data.assetId,'shared');assert.equal(engine.getSnapshot().runtime.resources.size,0);assert.equal(f.calls.filter(x=>x==='authorize').length,1);assert.equal(f.roots.get('p').formatVersion,17);decodeRecovery(f.roots.get('p'),registry);await engine.close();engine=await f.open();const handle=await engine.displayResource('shared');assert.equal(await(await fetch(handle.url)).text(),'original bytes');handle.release();assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);}finally{await engine.close();}});
|
||||
// 取消挂起的授权后迟到结果不能发布;后续权限拒绝的导入保留 partial 失败状态,不能生成 binding。
|
||||
test('document image authorization cannot publish after cancel and denied imports retain explicit failure',async()=>{const f=fixture(),engine=await f.open();let release;try{f.gate(new Promise(resolve=>release=resolve));const id=engine.imports.startDocument(exportedImages(),'canvas');for(let i=0;i<100&&!f.calls.includes('authorize');i++)await new Promise(resolve=>setTimeout(resolve,1));assert.ok(f.calls.includes('authorize'));assert.equal(engine.getSnapshot().document.elements.size,0);await engine.imports.cancel(id);release();f.gate();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);f.deny();const denied=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(denied).status,'partial');assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);}finally{release?.();await engine.close();}});
|
||||
// 同一文档批次接纳两个资源时,绑定和元素数量一致,完成记录可由恢复 codec 解码。
|
||||
test('one document batch can adopt multiple immutable resources with one consistent Root',async()=>{const f=fixture(),engine=await f.open();try{const id=engine.imports.startDocument(exportedImages(['shared','second']),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().runtime.assetBindings.size,2);assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(f.errors.length,0);decodeRecovery(f.roots.get('p'),registry);}finally{await engine.close();}});
|
||||
// 非法图片几何使导入事务拒绝,即使已经授权也不能留下 binding、内容、缓存字节或 depot write。
|
||||
test('rejected document image transaction never leaves an adopted binding or durable byte write',async()=>{const f=fixture(),engine=await f.open();try{const input=JSON.parse(await exportedImages().text());input.elements[0].geometry.width=-1;const id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.getSnapshot().runtime.assetBindings?.size??0,0);assert.equal(f.cache.size,0);assert.ok(!f.batches.some(batch=>batch.depot?.writes.length));}finally{await engine.close();}});
|
||||
// 第一次导入已缓存字节,第二份文档仍重新检查权限;拒绝后只保留第一份已发布内容。
|
||||
test('resource permission is checked again for another document even after its bytes are cached',async()=>{const f=fixture(),engine=await f.open();try{const first=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(first).status,'completed');f.deny();const second=engine.imports.startDocument(exportedImages(),'canvas');await engine.settle();assert.equal(engine.imports.getJobs().get(second).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.calls.filter(x=>x==='authorize').length>=2,true);}finally{await engine.close();}});
|
||||
|
||||
@@ -1,33 +1,49 @@
|
||||
/**
|
||||
* 共享资源 depot 的浏览器存储集成场景,使用真实 IndexedDB 事务及原始 object store 计数。
|
||||
* 覆盖跨项目引用、作用域隔离、回收扫描完整性、摘要去重、事务回滚,以及实际引擎上传后的字节提升。
|
||||
*/
|
||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
||||
// 注入断言并创建独立数据库;所有领取的 leader 统一记录,最后释放并删库。
|
||||
export async function assetDepotStorageCase(assert){
|
||||
const name=`depot-${crypto.randomUUID()}`,storage=createBrowserStorage({name}),scope={accountId:'a',workspaceId:'w',projectId:'p'},leaders=[];
|
||||
// 生成最小 image 并计算原始测试字节摘要;资源身份元数据与摘要共享字节分开保存。
|
||||
const image=n=>({formatVersion:1,rootRevision:n,localRevision:n,payload:{n}}),bytes=new Blob(['shared original'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),authority='https://source.test/w/assets';
|
||||
// 构造不同资源 ID 的相同字节引用,以及完整/不完整 manifest 和固定宽限期的回收调用。
|
||||
const ref=id=>({version:1,authority,digest,asset:{id,url:`https://source.test/w/assets/${id}`,width:2,height:1,bytes:bytes.size,mime_type:'image/png'}}),cut=(pins=[],writes=[],complete=true)=>({version:1,authority,pins,writes,complete}),collect=(leader,now,extra={})=>storage.assetDepot.collect(leader,{authority,now,retentionMs:10,limit:128,...extra});
|
||||
// 要求异步调用以指定错误码失败;正常返回或错误码不符都使测试失败。
|
||||
const rejects=async(fn,code)=>{try{await fn();}catch(e){assert(e.code===code,`expected ${code}, got ${e.code}`);return;}throw Error(`expected ${code}`);};
|
||||
// 按项目领取 leader 并记录到清理列表,让关闭项目的 manifest 仍可参与共享回收。
|
||||
const open=async(projectId)=>{const leader=await storage.claim({...scope,projectId});leaders.push(leader);return leader;};
|
||||
try{
|
||||
const p=await open('p'),q=await open('q');
|
||||
await storage.commit(p,{image:image(0),depot:cut([['one',['element:p']]], [{ref:ref('one'),bytes},{ref:ref('two'),bytes}])});
|
||||
await storage.commit(q,{image:image(0),depot:cut([['two',['history:q']]])});
|
||||
// 额外打开原始 IDB 连接用于检查物理行数,避免只凭公开读取接口推断去重。
|
||||
const database=await new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||
// 直接统计 asset-bytes 的物理摘要记录数,区分资源身份数与实际存储的字节副本数。
|
||||
const count=()=>new Promise((resolve,reject)=>{const tx=database.transaction('asset-bytes'),request=tx.objectStore('asset-bytes').count();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||
assert(await count()===1,'same digest was physically duplicated across identities');
|
||||
await p.release();assert((await collect(q,0)).pinnedIds.length===2,'closed project pins were lost');
|
||||
assert(await(await storage.assetDepot.read(q,authority,'one')).bytes.text()==='shared original','destination could not read shared bytes');
|
||||
// 分别换账户、工作区及 authority,确认共享缓存只在相同授权作用域内可读。
|
||||
const otherAccount=await storage.claim({...scope,accountId:'other'});leaders.push(otherAccount);assert(await storage.assetDepot.read(otherAccount,authority,'one')===undefined,'account cache leak');
|
||||
const otherWorkspace=await storage.claim({...scope,workspaceId:'other'});leaders.push(otherWorkspace);assert(await storage.assetDepot.read(otherWorkspace,authority,'one')===undefined,'workspace cache leak');
|
||||
assert(await storage.assetDepot.read(q,'https://other.test','one')===undefined,'authority cache leak');
|
||||
// 缺少索引、不完整或落后于最新 Root 的 manifest 都会阻止安全删除;随后补齐才能继续回收。
|
||||
const stale=await open('stale');await storage.commit(stale,{image:image(0)});assert((await collect(q,100)).blockedProjects.includes('stale'),'unindexed project allowed deletion');
|
||||
await storage.commit(stale,{image:image(0),depot:cut([],[],false)});assert(!(await collect(q,100)).complete,'opaque project allowed deletion');
|
||||
await storage.commit(stale,{image:image(0),depot:cut()});await storage.commit(stale,{image:image(1)});assert(!(await collect(q,100)).complete,'stale manifest allowed deletion');
|
||||
await storage.commit(stale,{image:image(1),depot:cut()});
|
||||
// 重新打开源项目清除引用,再经标记和宽限期回收其中一个身份;其他同摘要资源仍持有物理字节。
|
||||
const reopened=await open('p');await storage.commit(reopened,{image:image(1),depot:cut()});
|
||||
assert((await collect(q,100)).markedIds.includes('one'),'unreferenced resource was not marked');
|
||||
assert((await collect(q,110)).purgedIds.includes('one')&&await count()===1,'purging one identity deleted another identity bytes');
|
||||
await storage.commit(q,{image:image(1),depot:cut()});await collect(q,110);await collect(q,120);assert(await count()===0,'expired unpinned shared bytes retained');
|
||||
// 清除最后引用后字节应消失,但不可变身份与 purgedAt 仍保留,便于校验后续补回的内容。
|
||||
const purged=await storage.assetDepot.read(q,authority,'one');assert(purged.purgedAt===110&&!purged.bytes,'immutable identity lost or purged bytes exposed');
|
||||
// 摘要/元数据冲突必须整批拒绝,不能推进 rootRevision 或写入部分共享字节。
|
||||
await rejects(()=>storage.commit(q,{image:image(2),depot:cut([], [{ref:{...ref('one'),digest:'0'.repeat(64)},bytes}])}),'shared_asset_bytes_changed');assert((await storage.recover(q)).rootRevision===1,'invalid bytes advanced Root');
|
||||
await rejects(()=>storage.commit(q,{image:image(2),depot:cut([['one',['element:q']]], [{ref:{...ref('one'),asset:{...ref('one').asset,width:3}},bytes}])}),'shared_asset_identity_conflict');assert(await count()===0&&(await storage.recover(q)).rootRevision===1,'identity conflict partially committed bytes or Root');
|
||||
// Force a later files-store conflict after the depot puts: the whole transaction must roll back.
|
||||
@@ -37,15 +53,21 @@ export async function assetDepotStorageCase(assert){
|
||||
// A one-item sweep must provide a cursor and preserve each resource decision.
|
||||
const first=await collect(q,130,{limit:1});assert(first.nextAfter==='one','bounded sweep cursor missing');assert((await collect(q,130,{after:first.nextAfter,limit:1})).nextAfter===undefined,'bounded sweep did not finish');
|
||||
// Exercise the actual engine persistence entry point, rather than only hand-built batches.
|
||||
// 从手工 batch 检查切换到真实引擎:stageFiles、确认上传及关闭后,再检查物理文件行。
|
||||
const projectId='engine-project',registry=createRegistry(builtinComponents());let document=createDocument(projectId,[{id:'canvas',parent:null}],[]),version=1,counter=0,resource,uploads=0;
|
||||
// 模拟上传回执及 authority;此场景不应触发已有资源接纳,上传返回原始字节的服务端身份。
|
||||
const receipts=new Map(),assets={authority:{scope:authority,async resolve(){throw Error('unexpected adoption');},async read(){throw Error('unexpected hydration');}},sourceURL:id=>`https://source.test/w/assets/${id}`,displayURL:id=>`/assets/${id}`,async upload(request,data){uploads++;const raw=JSON.parse(request.body);return{outcome:'applied',canonical:{id:raw.resource_id,url:assets.sourceURL(raw.resource_id),width:2,height:1,bytes:data.size,mime_type:data.type}};},async query(){return{outcome:'unresolved'};},accept:(_request,status)=>status.canonical};
|
||||
// 文档提交保存新 canonical 基线;资源上传和文档确认在此使用内存服务,存储端仍是真 IDB。
|
||||
const persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
|
||||
const engine=await openProjectEngine({scope:{...scope,projectId},registry,document,versions:new Map([['canvas',1]]),storage,assets,persistence,createId:()=>`engine-${++counter}`});
|
||||
try{
|
||||
// 创建图片与上传任务,再有限轮询 stored 状态,避免在异步提升字节前检查存储。
|
||||
const staged=engine.stageFiles([{name:'source.png',bytes}],([handle],tx)=>{resource=handle.id;tx.dispatch({type:'element.create',element:{id:'picture',incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:'picture',text:'',assetId:handle.id,src:handle.src}}});});assert(staged.status!=='rejected','engine image staging failed');
|
||||
let ready=false;for(let i=0;i<200;i++){if(engine.getSnapshot().runtime.resources.get(resource)?.stored){ready=true;break;}await new Promise(resolve=>setTimeout(resolve,5));}assert(ready,'engine upload did not settle');
|
||||
}finally{await engine.close();}
|
||||
// 读取引擎项目的 files 行,要求上传确认后为 fileVersion=1 指针,而非保留第二份 Blob。
|
||||
const physical=await new Promise((resolve,reject)=>{const request=database.transaction('files').objectStore('files').get([JSON.stringify([scope.accountId,scope.workspaceId,projectId]),resource]);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});assert(physical?.fileVersion===1&&!(physical instanceof Blob),'actual engine upload retained duplicate project bytes');
|
||||
// 共享 depot 应能读到提升后的原字节;上传只发生一次,关闭引擎的持久化 pin 仍保护资源。
|
||||
const promoted=await storage.assetDepot.read(q,authority,resource);assert(promoted?.bytes&&await promoted.bytes.text()==='shared original'&&uploads===1,'engine did not promote acknowledged upload bytes');
|
||||
assert((await collect(q,200)).pinnedIds.includes(resource),'closed engine did not retain durable document/history pins');
|
||||
await q.release();await rejects(()=>collect(q,200),'storage_leader_lost');database.close();
|
||||
|
||||
@@ -1,39 +1,53 @@
|
||||
/**
|
||||
* 共享资源 depot、原始二进制传输及 Foundry authority 的单元测试。缓存降低读取成本,不能代替授权或身份验证。
|
||||
* 控制权限和生命周期可检查迟到结果隔离;字节预算按实际流内容验证,不依赖声明长度。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createAssetDepot} from '../../src/lib/canvas-runtime/asset-depot.ts';
|
||||
import {createFoundryTransport} from '../../src/lib/canvas-service/foundry.ts';
|
||||
import {createFoundryAssets} from '../../src/lib/canvas-service/assets.ts';
|
||||
// 对真实 Blob 字节计算十六进制 SHA-256,用于检查缓存/服务引用与内容是否匹配。
|
||||
const digest=async bytes=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');
|
||||
// 准备可替换缓存、权限及身份状态的 depot;resolve/read 分别计数,区分重新授权和重新下载。
|
||||
async function fixture(){
|
||||
const bytes=new Blob(['original'],{type:'image/png'}),scope={accountId:'account',workspaceId:'workspace'},leader={scope:{...scope,projectId:'p'},active:true},ref={version:1,authority:'authority',digest:await digest(bytes),asset:{id:'asset',url:'https://source.test/asset',width:2,height:1,bytes:bytes.size,mime_type:'image/png'}};
|
||||
let cached,allowed=true,resolves=0,reads=0,current=true;
|
||||
// port 仅提供可注入缓存;current 判断身份生命周期,digest 检查内容,collect 在此 fixture 中不做回收。
|
||||
const depot=createAssetDepot({scope,port:{async read(){return cached;},async collect(){return{complete:true};}},current:()=>current,digest});
|
||||
// 每次 resolve 都检查权限并返回引用,read 单独提供原始字节,便于断言热缓存仍授权。
|
||||
const authority={scope:ref.authority,async resolve(){resolves++;if(!allowed)throw Error('denied');return ref;},async read(){reads++;return bytes;}};
|
||||
return{depot,scope,leader,ref,bytes,authority,setCached:v=>cached=v,deny:()=>allowed=false,retire:()=>current=false,counts:()=>({resolves,reads})};
|
||||
}
|
||||
// 首次接纳下载原字节,热缓存再次接纳只重新授权;撤权后不能以缓存绕过 resolve,读取次数保持不变。
|
||||
test('new cross-project adoption reauthorizes even when immutable bytes are warm',async()=>{
|
||||
const f=await fixture(),signal=new AbortController().signal;
|
||||
assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).bytes,f.bytes);
|
||||
f.setCached({ref:f.ref,bytes:f.bytes});assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).bytes,f.bytes);assert.deepEqual(f.counts(),{resolves:2,reads:1});
|
||||
f.deny();await assert.rejects(f.depot.authorize(f.leader,f.authority,'asset',signal),/denied/);assert.deepEqual(f.counts(),{resolves:3,reads:1});f.depot.close();
|
||||
});
|
||||
// depot 捕获作用域而不受外部 scope 改写影响;缓存身份冲突、字节篡改及当前身份失效分别拒绝。
|
||||
test('depot captures scope, validates cached identity and bytes, and rejects revoked lifecycle',async()=>{
|
||||
const f=await fixture(),signal=new AbortController().signal;f.scope.accountId='mutated';assert.equal((await f.depot.authorize(f.leader,f.authority,'asset',signal)).ref.asset.id,'asset');
|
||||
f.setCached({ref:{...f.ref,digest:'0'.repeat(64)},bytes:f.bytes});await assert.rejects(f.depot.authorize(f.leader,f.authority,'asset',signal),{code:'shared_asset_identity_conflict'});
|
||||
f.setCached({ref:f.ref,bytes:new Blob(['tampered'])});await assert.rejects(f.depot.read(f.leader,f.ref),{code:'shared_asset_bytes_changed'});
|
||||
f.retire();await assert.rejects(f.depot.read(f.leader,f.ref),{code:'identity_changed'});f.depot.close();
|
||||
});
|
||||
// authority 结果返回前关闭 depot,迟到结果应以 identity_changed 拒绝,不能写入元数据缓存。
|
||||
test('late authority result after close never enters the metadata cache',async()=>{
|
||||
const f=await fixture();let done,started;const ready=new Promise(resolve=>started=resolve),wait=new Promise(resolve=>done=resolve);
|
||||
const promise=f.depot.authorize(f.leader,{...f.authority,async resolve(){started();await wait;return f.ref;}},'asset',new AbortController().signal);await ready;f.depot.close();done();await assert.rejects(promise,{code:'identity_changed'});assert.equal(f.depot.getResources().size,0);
|
||||
});
|
||||
// 二进制响应保持原字节和 MIME;实际流超过 20 MiB 时取消读取,读取途中身份变化同样拒绝。
|
||||
test('binary asset transport preserves original bytes and enforces identity and actual byte budgets',async()=>{
|
||||
const accountId='11111111-1111-4111-8111-111111111111';let current=true,cancelled=false;
|
||||
// 用指定 Response 替代 fetch,保留真实 transport 的流读取、大小限制及身份检查。
|
||||
const transport=response=>createFoundryTransport({accountId,isCurrentIdentity:()=>current,fetch:async()=>response});
|
||||
const result=await transport(new Response('original',{headers:{'content-type':'image/png'}}))({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'});assert.equal(await result.bytes.text(),'original');assert.equal(result.bytes.type,'image/png');
|
||||
const oversized=new Response(new ReadableStream({start(c){c.enqueue(new Uint8Array(20*1024*1024+1));},cancel(){cancelled=true;}}));await assert.rejects(transport(oversized)({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'}),{code:'asset_byte_budget_exceeded'});assert.equal(cancelled,true);
|
||||
const identityResponse=new Response(new ReadableStream({pull(c){current=false;c.enqueue(new Uint8Array([1]));}}));await assert.rejects(transport(identityResponse)({method:'GET',path:'asset',responseFormat:'asset-bytes-v1'}),{code:'identity_changed'});
|
||||
});
|
||||
// Foundry authority 走 metadata 和原字节两条请求路径,验证已支持的版本及 digest;未来元数据版本明确拒绝解释。
|
||||
test('Foundry resource authority validates version, resource identity and canonical URL',async()=>{
|
||||
const id='22222222-2222-4222-8222-222222222222',workspaceId='33333333-3333-4333-8333-333333333333';let version=1;const bytes=new Blob(['original']),sha256=await digest(bytes),calls=[];
|
||||
const assets=createFoundryAssets({workspaceId,transport:async request=>{calls.push(request);return request.responseFormat==='asset-bytes-v1'?{status:200,body:null,bytes}:{status:200,body:{metadata_version:version,sha256,asset:{id,url:assets.sourceURL(id),width:2,height:1,bytes:bytes.size,mime_type:'image/png'}}};}});
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
/**
|
||||
* Foundry 上传 adapter 与 multipart transport 的协议测试。冻结请求关联资源 ID、操作 ID、摘要及字节长度。
|
||||
* 回执未知或权限失败不等于零写入,显示地址可由宿主路由,但 canonical 身份和 authority 必须保持。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFoundryAssets, createFoundryTransport } from '../../src/lib/canvas-service/index.ts';
|
||||
// 生成固定格式 UUID,满足 adapter 的身份校验且让不同角色的 ID 易于区分。
|
||||
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
// 构造真实 Blob、摘要、冻结上传 body 与匹配成功回执;respond 只替换模拟服务端响应。
|
||||
async function fixture() {
|
||||
const blob = new Blob(['immutable image'], { type: 'image/png' });
|
||||
const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', await blob.arrayBuffer()))].map(value => value.toString(16).padStart(2, '0')).join('');
|
||||
const resource = { id: id(1), operationId: id(2), name: 'picture.png', byteLength: blob.size, mimeType: blob.type, digest };
|
||||
// 上传没有 canvas writer,但仍拥有 requestId/operationId 和不可变协议字段,不能重试时换身份。
|
||||
const request = { kind: 'asset.upload', protocolVersion: 2, id: id(3), operationIds: [resource.operationId], canvasIds: [], expectedVersions: {}, phase: 'frozen', outcome: 'unresolved', generation: 1, attempt: 0,
|
||||
body: JSON.stringify({ protocol_version: 2, request_id: id(3), resource_id: resource.id, operation_id: resource.operationId, name: resource.name, mime_type: resource.mimeType, byte_length: resource.byteLength, sha256: resource.digest }) };
|
||||
// 成功回执使用当前 workspace 的规范 asset URL;测试会分别篡改资源身份、大小、地址和尺寸。
|
||||
const calls = [], receipt = { protocol_version: 2, request_id: request.id, status: 'applied', asset: { id: resource.id, bytes: resource.byteLength, mime_type: 'image/png', width: 2, height: 1, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${id(4)}/assets/${resource.id}` } };
|
||||
let response = { status: 201, body: receipt };
|
||||
const assets = createFoundryAssets({ workspaceId: id(4), transport: async input => { calls.push(input); return response; } });
|
||||
return { blob, resource, request, calls, receipt, assets, respond: next => { response = next; } };
|
||||
}
|
||||
|
||||
// 上传保持文件名、原字节及请求身份;accept 拒绝不匹配回执,换字节重试在发请求前就被阻塞。
|
||||
test('asset adapter preserves byte identity and headers and validates the scoped applied receipt', async () => {
|
||||
const f = await fixture(), result = await f.assets.upload(f.request, f.blob, new AbortController().signal);
|
||||
assert.equal(result.outcome, 'applied'); assert.equal(f.calls[0].requestId, f.request.id); assert.equal(f.calls[0].resourceId, f.resource.id);
|
||||
@@ -27,6 +36,7 @@ test('asset adapter preserves byte identity and headers and validates the scoped
|
||||
assert.equal(changed.outcome, 'unresolved'); assert.equal(changed.blocked, true); assert.equal(f.calls.length, 1);
|
||||
});
|
||||
|
||||
// 宿主只替换 displayURL,不能改变 sourceURL 或 authority;单纯计算显示路径也不能触发网络。
|
||||
test('a headless host supplies display URLs without changing canonical asset identity or authority',async()=>{
|
||||
const options={workspaceId:id(4),transport:async()=>{throw Error('display routing must not make a request');}};
|
||||
const browser=createFoundryAssets(options),host=createFoundryAssets({...options,displayURL:asset=>`https://host.example/preview/${encodeURIComponent(asset)}`});
|
||||
@@ -35,6 +45,7 @@ test('a headless host supplies display URLs without changing canonical asset ide
|
||||
assert.equal(host.sourceURL(id(1)),browser.sourceURL(id(1)));assert.equal(host.authority.scope,browser.authority.scope);
|
||||
});
|
||||
|
||||
// 查询 404 unknown 保持 unresolved、403 阻塞自动处理,成功响应若 requestId 错误仍不能当作已应用证明。
|
||||
test('missing and rejected upload receipts never become proof of zero writes', async () => {
|
||||
const f = await fixture(), signal = new AbortController().signal;
|
||||
f.respond({ status: 404, body: { protocol_version: 2, request_id: f.request.id, status: 'unknown' } });
|
||||
@@ -47,6 +58,7 @@ test('missing and rejected upload receipts never become proof of zero writes', a
|
||||
assert.equal((await f.assets.upload(f.request, f.blob, signal)).outcome, 'unresolved');
|
||||
});
|
||||
|
||||
// FormData 保持原对象交给 fetch,由 fetch 生成 multipart boundary;同时传入账户、资源和幂等身份头。
|
||||
test('multipart browser transport leaves the boundary to fetch and binds the expected account', async () => {
|
||||
let captured;
|
||||
const transport = createFoundryTransport({ accountId: id(5), isCurrentIdentity: () => true, fetch: async (url, init) => { captured = { url, init }; return Response.json({ status: 'applied' }); } });
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
/**
|
||||
* 浏览器 files 表与共享 depot 的联动场景:确认上传后项目文件变为指针,物理字节按摘要共享。
|
||||
* 覆盖 MIME 恢复、资源级回收、事务回滚、DB v3 到 v5 懒升级及旧状态提交/新接纳之间的回收竞态。
|
||||
* 这些导出函数由浏览器 runner 调用;Node 单元测试本身不能证明真实 IDB 场景已执行。
|
||||
*/
|
||||
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
||||
// 在独立数据库内直接探查 files/asset-bytes;以相同字节、不同原始 MIME 的两个资源检查指针语义。
|
||||
export async function depotFilesCase(assert){
|
||||
const name=`depot-files-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},authority='https://assets.test',a=new Blob(['shared bytes'],{type:'image/jpeg'}),b=new Blob(['shared bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await a.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),storage=createBrowserStorage({name});let p,q,raw;
|
||||
// 构造服务端资源、上传状态、最小 image 和 manifest;stored=false 用于模拟尚未确认的上传。
|
||||
const ref=id=>({version:1,authority,digest,asset:{id,url:`${authority}/${id}`,width:2,height:1,bytes:a.size,mime_type:'image/png'}}),resource=(id,mimeType,stored=true)=>({id,operationId:`upload-${id}`,digest,byteLength:a.size,mimeType,...(stored?{stored:ref(id).asset}:{})}),image=(n,resources)=>({formatVersion:1,rootRevision:n,localRevision:n,payload:{runtime:{resources}}}),cut=(pins=[],writes=[])=>({version:1,authority,complete:true,pins,writes});
|
||||
// 项目文件主键仍含项目作用域,两个 ResourceId 可引用同一摘要;pin 来源分别记录元素身份。
|
||||
const key=JSON.stringify(['a','w','p']),rows=[['one',resource('one',a.type)],['two',resource('two',b.type)]],pins=[['one',['element:one']],['two',['element:two']]],writes=[{ref:ref('one'),bytes:a},{ref:ref('two'),bytes:b}];
|
||||
// 另开原始 IDB 连接,检查数据库版本及底层记录,不借助待验证的 storage.readFile 推断行类型。
|
||||
const openRaw=()=>new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||
// 读取任意 object store 的单行,用于断言 Blob 到指针的迁移及后续故障注入。
|
||||
const get=(store,key)=>new Promise((resolve,reject)=>{const tx=raw.transaction(store),request=tx.objectStore(store).get(key);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||
// 统计物理行,证明按 digest 去重及最后引用清除后的实际字节删除。
|
||||
const count=store=>new Promise((resolve,reject)=>{const tx=raw.transaction(store),request=tx.objectStore(store).count();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||
// 直接以事务覆盖底层行以注入未来 fileVersion 或错误摘要;等待事务完成,确保后续读取看到故障。
|
||||
const put=(store,key,value)=>new Promise((resolve,reject)=>{const tx=raw.transaction(store,'readwrite');tx.objectStore(store).put(value,key);tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);});
|
||||
// 断言指定异步错误码;预期失败却返回成功时也抛错,避免 try/catch 吞掉问题。
|
||||
const rejects=async(fn,code)=>{try{await fn();}catch(error){assert(error.code===code,`expected ${code}, got ${error.code}`);return;}throw Error(`expected ${code}`);};
|
||||
// 固定 authority、宽限期和扫描上限,仅改变当前时间来分别触发标记与 purge。
|
||||
const collect=now=>storage.assetDepot.collect(p,{authority,now,retentionMs:10,limit:128});
|
||||
try{
|
||||
p=await storage.claim(scope);q=await storage.claim({...scope,projectId:'q'});raw=await openRaw();assert(raw.version===5,'pointer storage did not upgrade to DB v5');
|
||||
@@ -15,37 +29,53 @@ export async function depotFilesCase(assert){
|
||||
assert(await get('files',[key,'one']) instanceof Blob,'unconfirmed upload lost its original bytes');
|
||||
// The first pointer is converted before the second row fails. Everything must roll back.
|
||||
await rejects(()=>storage.commit(p,{image:image(1,[rows[0],['two',resource('two','image/webp')]]),depot:cut(pins,writes)}),'shared_asset_bytes_changed');assert(await get('files',[key,'one']) instanceof Blob&&await count('asset-bytes')===0&&(await storage.recover(p)).rootRevision===0,'failed promotion partially converted files/bytes/Root');
|
||||
// 所有字段合法时将两个项目 Blob 提升为同摘要字节及独立指针,随后仍按各自原始 MIME 读回。
|
||||
await storage.commit(p,{image:image(1,rows),depot:cut(pins,writes)});assert(await count('asset-bytes')===1,'same digest occupied multiple shared byte entries');for(const id of ['one','two']){const local=await get('files',[key,id]);assert(!(local instanceof Blob)&&local.fileVersion===1&&local.digest===digest,'stored upload still retained a project-local Blob');}
|
||||
assert((await storage.readFile(p,'one')).type===a.type&&(await storage.readFile(p,'two')).type===b.type,'pointer read changed the original File MIME');assert(await(await storage.readFile(p,'one')).text()==='shared bytes','pointer did not resolve original bytes');
|
||||
// scanFiles 返回逻辑文件字节数/MIME;实现不应暴露指针行的物理存储形态。
|
||||
const inventory=await storage.scanFiles(p,{expectedRootRevision:1,limit:128});assert(inventory.files.length===2&&inventory.files[0].mimeType===a.type&&inventory.files.every(row=>row.byteLength===a.size),'inventory did not expose logical file metadata');
|
||||
// q 的历史 pin 保护 one;p 清空 manifest 后先回收 two,不能因为同摘要仍在就让已 purge 的 two 继续可读。
|
||||
await storage.commit(q,{image:image(0,[]),depot:cut([['one',['history:q']]])});await storage.commit(p,{image:image(2,rows),depot:cut()});await collect(0);await collect(10);assert(await count('asset-bytes')===1,'purging one ResourceId deleted another ResourceId bytes');assert(await storage.readFile(p,'two')===undefined,'purged identity resolved through another live digest owner');assert((await storage.scanFiles(p,{expectedRootRevision:2,limit:128})).files.length===1,'inventory advertised an evicted pointer as readable bytes');
|
||||
// 最后的 q pin 清除后,再经标记及宽限期才能删除共享摘要字节。
|
||||
await storage.commit(q,{image:image(1,[]),depot:cut()});await collect(10);await collect(20);assert(await count('asset-bytes')===0&&await storage.readFile(p,'one')===undefined,'last unreferenced digest was retained');
|
||||
// 授权补回 one 只恢复它的指针;two 即使共用摘要也不能被顺手复活。
|
||||
await storage.commit(p,{image:image(3,rows),depot:cut([['one',['element:one']]],[writes[0]])});assert(await count('asset-bytes')===1&&await(await storage.readFile(p,'one')).text()==='shared bytes','same identity could not rehydrate a file pointer');assert(await storage.readFile(p,'two')===undefined,'rehydrating one identity revived another purged identity');
|
||||
// 注入未来指针格式应报告缺少解释器;已知格式的摘要损坏应报告 corrupt_file_store,最后恢复原行。
|
||||
const pointer=await get('files',[key,'one']);await put('files',[key,'one'],{...pointer,fileVersion:2});await rejects(()=>storage.readFile(p,'one'),'file_interpreter_unavailable');await rejects(()=>storage.scanFiles(p,{expectedRootRevision:3,limit:128}),'file_interpreter_unavailable');await put('files',[key,'one'],{...pointer,digest:'0'.repeat(64)});await rejects(()=>storage.readFile(p,'one'),'corrupt_file_store');await put('files',[key,'one'],pointer);
|
||||
// 旧版 DB 连接不能打开更高版本数据库,必须得到 VersionError,避免旧 reader 把指针误当 Blob。
|
||||
const old=await new Promise(resolve=>{const request=indexedDB.open(name,3);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(old==='VersionError','DB v3 reader accepted pointer file rows');
|
||||
}finally{raw?.close();await p?.release();await q?.release();storage.close();indexedDB.deleteDatabase(name);}
|
||||
}
|
||||
|
||||
// 手工建 v3 数据库及校验有效的 checkpoint/Blob,交给新 storage 打开,再在同修订 reindex 时懒提升到指针。
|
||||
export async function depotFilesUpgradeCase(assert){
|
||||
const name=`depot-upgrade-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},key=JSON.stringify(['a','w','p']),authority='https://assets.test',bytes=new Blob(['old bytes'],{type:'image/png'}),hex=buffer=>[...new Uint8Array(buffer)].map(n=>n.toString(16).padStart(2,'0')).join(''),digest=hex(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer())),asset={id:'old',url:`${authority}/old`,width:2,height:1,bytes:bytes.size,mime_type:bytes.type},image={formatVersion:1,rootRevision:0,localRevision:0,payload:{runtime:{resources:[['old',{id:'old',digest,byteLength:bytes.size,mimeType:bytes.type,stored:asset}]]}}},checksum=hex(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(image))));
|
||||
// 模拟旧 schema 的 object stores,不通过新实现创建,确保升级路径确实面对旧数据库。
|
||||
let raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name,3);request.onupgradeneeded=()=>{for(const store of ['metadata','checkpoints','journal','files','asset-depot','asset-bytes','asset-pins'])request.result.createObjectStore(store);};request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||
// 在单个旧版事务里写 metadata、带 checksum 的 checkpoint 和原始 files Blob,然后关闭旧连接。
|
||||
await new Promise((resolve,reject)=>{const tx=raw.transaction(['metadata','checkpoints','files'],'readwrite');tx.objectStore('metadata').put({fence:1,throughRevision:0,checkpointRevision:0},key);tx.objectStore('checkpoints').put({image,checksum},[key,0]);tx.objectStore('files').put(bytes,[key,'old']);tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);});raw.close();const storage=createBrowserStorage({name});let leader;
|
||||
try{leader=await storage.claim(scope);assert((await storage.recover(leader)).rootRevision===0,'v3 checkpoint lost during upgrade');await storage.commit(leader,{image,depot:{version:1,authority,complete:true,pins:[['old',['element:old']]],writes:[{ref:{version:1,authority,digest,asset},bytes}]}});assert(await(await storage.readFile(leader,'old')).text()==='old bytes','idempotent reindex lost old bytes');raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const row=await new Promise((resolve,reject)=>{const request=raw.transaction('files').objectStore('files').get([key,'old']);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});assert(raw.version===5&&row.fileVersion===1&&!(row instanceof Blob),'old project was not lazily promoted');raw.close();}finally{await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
||||
}
|
||||
|
||||
// 控制目标项目旧的无引用提交停在 IDB 事务之前,允许回收旧字节,再验证新接纳补回的字节不会被旧提交丢弃。
|
||||
export async function depotAdoptionRaceCase(assert){
|
||||
const {openProjectEngine}=await import('../../src/lib/canvas-runtime/engine.ts'),{createDocument,createRegistry,builtinComponents,applyChanges}=await import('../../src/lib/canvas-core/index.ts');
|
||||
const name=`depot-race-${crypto.randomUUID()}`,base=createBrowserStorage({name}),scope={accountId:'a',workspaceId:'w',projectId:'source'},authority='https://assets.test',bytes=new Blob(['race bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),ref={version:1,authority,digest,asset:{id:'shared',url:`${authority}/shared`,width:2,height:1,bytes:bytes.size,mime_type:bytes.type}};let leader,engine,release,started,gate,blocked=false,version=1,hydrations=0;const receipts=new Map();
|
||||
let document=createDocument('target',[{id:'canvas',parent:null}],[]);const registry=createRegistry(builtinComponents());
|
||||
// 只阻塞一次 commit,并检查该旧 batch 没有 shared pin;其余接口仍委托真实 browser storage。
|
||||
const storage={...base,async commit(held,batch){if(gate&&!blocked){blocked=true;assert(!batch.depot.pins.some(([id])=>id==='shared'),'older Root was not unpinned');started();await gate;}return base.commit(held,batch);}};
|
||||
// authority 返回同一不可变身份,read 计数实际补回字节次数;任何误走上传的路径都会报错。
|
||||
const assets={authority:{scope:authority,async resolve(){return ref;},async read(){hydrations++;return bytes;}},sourceURL:id=>`${authority}/${id}`,displayURL:id=>`/assets/${id}`,async upload(){throw Error('unexpected upload');},async query(){throw Error('unexpected upload query');},accept(){throw Error('unexpected upload receipt');}},persistence={compile(intent,id,snapshot){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request){document=applyChanges(document,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document,versions:new Map([['canvas',++version]])});return{outcome:'applied'};},async query(){return{outcome:'unresolved'};},canonical:request=>receipts.get(request.id)};
|
||||
// 构造接纳图片并在同步事务内发布,之后用旧图删除/历史淘汰消除旧引用。
|
||||
const picture=(handle,id)=>({id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:2,height:1,rotation:0},data:{title:id,text:'',assetId:handle.id,src:handle.src}}),adopt=id=>engine.adoptResource('shared',(handle,tx)=>tx.dispatch({type:'element.create',element:picture(handle,id)}));
|
||||
try{
|
||||
leader=await storage.claim(scope);await storage.commit(leader,{image:{formatVersion:1,rootRevision:0,localRevision:0,payload:null},depot:{version:1,authority,complete:true,pins:[],writes:[{ref,bytes}]}});
|
||||
engine=await openProjectEngine({scope:{...scope,projectId:'target'},registry,document,versions:new Map([['canvas',1]]),storage,assets,persistence,historyLimit:1});assert((await adopt('old')).status==='applied','initial adoption failed');await engine.settle();engine.editor.dispatch({type:'element.remove',target:{id:'old',incarnation:1}});await engine.settle();engine.editor.dispatch({type:'element.create',element:{...picture({id:'unused',src:'unused'},'text'),componentId:'text',data:{title:'text',text:''}}});await engine.settle();
|
||||
// 先等待旧无引用 batch 进入屏障,再进行 GC;这控制了可重现的竞态顺序,避免靠时间猜测。
|
||||
const ready=new Promise(resolve=>started=resolve);gate=new Promise(resolve=>release=resolve);engine.editor.updateRuntime(previous=>({...previous}));await ready;
|
||||
// Old commit has not acquired IDB yet. A complete unpinned manifest permits GC.
|
||||
await storage.assetDepot.collect(leader,{authority,now:0,retentionMs:0,limit:128});const purged=await storage.assetDepot.collect(leader,{authority,now:0,retentionMs:0,limit:128});assert(purged.purgedIds.includes('shared'),'GC could not retire the actually unreferenced identity');assert(!(await storage.assetDepot.read(leader,authority,'shared')).bytes,'GC left bytes before rehydration');
|
||||
// GC 已真实删除字节后,新接纳通过 authority.read 补回;释放旧 commit 后检查新字节和新 manifest 均保留。
|
||||
assert((await adopt('new')).status==='applied'&&hydrations===1,'fresh adoption did not rehydrate original bytes');release();await engine.settle();const restored=await storage.assetDepot.read(leader,authority,'shared');assert(restored.bytes&&await restored.bytes.text()==='race bytes','older Root released the new adoption bytes before the matching durable cut');const stillPinned=await storage.assetDepot.collect(leader,{authority,now:1,retentionMs:0,limit:128});assert(stillPinned.pinnedIds.includes('shared'),'new Root did not atomically persist resource pins');
|
||||
}finally{release?.();await engine?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
/**
|
||||
* 导入源文件退休元数据的不可变更新与身份保护测试。已取消 job 的源文件仍需完整引用扫描才能进入回收。
|
||||
* 这里只验证标记、归属及归档演进,不实际删除文件字节。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.ts';
|
||||
import {markImportFiles,importFiles,prepareImportFileRetention} from '../../src/lib/canvas-runtime/import-file-retention.ts';
|
||||
// 建立已取消 native-ndjson 导入任务及空资源表;coverage 默认返回完整且无 pin 的扫描结论。
|
||||
const job={...createImportJob({id:'job',projectId:'P',sourceFingerprint:'source',parserVersion:1,source:{kind:'native-ndjson',fileId:'source',byteLength:10,targetCanvasId:'P'}}),status:'cancelled'},runtime={imports:new Map([['job',job]]),resources:new Map()},coverage=()=>({complete:true,pins:new Map()});
|
||||
// 新增退休标记只生成新 runtime/job 并递增 generation;相同标记是 no-op,重新被引用或扫描不完整时清除退休资格。
|
||||
test('source retention marks are immutable metadata with monotonic job generations and per-file clearing',()=>{
|
||||
const next=markImportFiles(runtime,new Map([['source',{version:1,unreferencedAt:100}]]));assert.notEqual(next,runtime);assert.equal(job.source.retirement,undefined);assert.equal(next.imports.get('job').generation,1);assert.ok(Object.isFrozen(next.imports.get('job').source.retirement));validateImportJob(next.imports.get('job'));assert.equal(markImportFiles(next,new Map([['source',{version:1,unreferencedAt:100}]])),next);
|
||||
const pinned=prepareImportFileRetention(next,next,()=>({complete:true,pins:new Map([['source',new Set(['export'])]])}),new Set());assert.equal(pinned.imports.get('job').source.retirement,undefined);assert.equal(pinned.imports.get('job').generation,2);assert.equal(next.imports.get('job').source.retirement.unreferencedAt,100);
|
||||
const incomplete=prepareImportFileRetention(next,next,()=>({complete:false,pins:new Map()}),new Set());assert.equal(incomplete.imports.get('job').source.retirement,undefined);
|
||||
});
|
||||
// 允许源文件向 previousFiles 归档,但拒绝丢失归属、与资源重名、重复 job 共享身份或回退已 purge 元数据;非法版本/时间也拒绝。
|
||||
test('source ownership, archive direction and completed purge metadata cannot be silently rewritten',()=>{
|
||||
const archived={...job,source:{...job.source,fileId:'next',previousFiles:[{fileId:'source',byteLength:10}]}};assert.equal(importFiles(new Map([['job',archived]])).size,2);assert.equal(prepareImportFileRetention(runtime,{...runtime,imports:new Map([['job',archived]])},coverage,new Set()).imports.get('job'),archived);
|
||||
assert.throws(()=>prepareImportFileRetention(runtime,{...runtime,imports:new Map()},coverage,new Set()),/import_file_identity_conflict/);assert.throws(()=>prepareImportFileRetention(runtime,{...runtime,resources:new Map([['source',{}]])},coverage,new Set()),/import_file_identity_conflict/);
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
/**
|
||||
* 资源引用扫描测试:工作文档、ACK、历史及显式 owner 都可能持有资源;无法解释的数据必须让扫描 complete=false。
|
||||
* 缓存只复用不可变历史范围的结构事实,不能缓存外部可变表成员,也不能忽略当前 registry 的资源字段定义。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,describeHistoryTransition} from '../../src/lib/canvas-core/index.ts';
|
||||
import {createResourcePinScan,resourcePins} from '../../src/lib/canvas-runtime/resources.ts';
|
||||
import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts';
|
||||
// 准备内置定义、空历史及图片/文本工厂;doc 总是建立独立文档,用于区分 working 与 ACK owner。
|
||||
const registry=createRegistry(builtinComponents()),empty={undo:[],redo:[],entries:new Map()},node=(id,asset)=>({id,incarnation:1,ownerCanvasId:'root',componentId:asset?'image':'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...(asset?{assetId:asset}:{})}}),doc=(records=[])=>createDocument('p',[{id:'root',parent:null}],records);
|
||||
// 用 Proxy 计数证明不可变历史范围只准备一次;working/ACK 分别扫描,最终 pin 保留各自来源而不混淆。
|
||||
test('immutable history ranges are reused across retention passes while working and ACK keep separate owners',()=>{
|
||||
const entry={id:'h',label:'h',changes:[{kind:'element',id:'past',before:null,after:node('past','historical')}],operationIds:[]},raw=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1);let reads=0;const transition=new Proxy(raw,{get(target,key,receiver){if(key==='entries')reads++;return Reflect.get(target,key,receiver);}}),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])},scan=createResourcePinScan(empty,ledger,registry),preparationReads=reads;assert.ok(preparationReads>0);scan(doc([node('text')]));scan(doc([node('remote','ack')]));assert.equal(reads,preparationReads);
|
||||
const snapshot={document:doc([node('text')]),history:empty,runtime:{ledger,acknowledged:doc([node('remote','ack')]),resourcePins:new Map(),resources:new Map(),runs:new Map()}};reads=0;const result=resourceRetentionPins(snapshot,registry);assert.equal(reads,0);assert.equal(result.complete,true);assert.deepEqual([...result.pins.get('historical')],['history-transition:o']);assert.deepEqual([...result.pins.get('ack')],['acknowledged:element:remote:1']);
|
||||
});
|
||||
// 历史字段修改可从已解决创建记录推断类型,并按 history/显式 owner 顺序保留资源;缺少类型证据时改为不完整扫描。
|
||||
test('historical data fields retain schemas from resolved creations, conservative fallback pins and explicit owner order',()=>{
|
||||
const field={kind:'field',target:{id:'deleted',incarnation:1},field:'data.assetId',before:'old',after:'new'},history={undo:['h'],redo:[],entries:new Map([['h',{id:'h',label:'h',changes:[field],operationIds:[]}]])},ledger={operations:new Map([['resolved',{phase:'resolved',intent:{id:'resolved',sequence:1,operationType:'document.change',changes:[{kind:'element',id:'deleted',before:null,after:node('deleted','unreferenced')}]} }]])},snapshot={document:doc(),history},result=resourcePins(snapshot,ledger,registry,new Map([['explicit',['old','extra']]]));assert.equal(result.complete,true);assert.deepEqual([...result.pins].map(([id,owners])=>[id,[...owners]]),[['old',['history:h','explicit']],['new',['history:h']],['extra',['explicit']]]);ledger.operations.clear();assert.equal(resourcePins(snapshot,ledger,registry,new Map()).complete,false);
|
||||
});
|
||||
// 新扫描必须看到可变 history.entries 后加的成员;即使已解决操作没有资源字段,损坏的 historyTransition 也不能跳过校验。
|
||||
test('new passes reread mutable input membership and still reject malformed resolved evidence with no resource fields',()=>{
|
||||
const history={undo:[],redo:[],entries:new Map()},ledger={operations:new Map()},snapshot={document:doc(),history};assert.equal(resourcePins(snapshot,ledger,registry,new Map()).pins.size,0);history.entries.set('h',{id:'h',label:'h',changes:[{kind:'element',id:'image',before:null,after:node('image','late')}],operationIds:[]});assert.ok(resourcePins(snapshot,ledger,registry,new Map()).pins.has('late'));ledger.operations.set('bad',{phase:'resolved',intent:{id:'bad',sequence:1,changes:[],historyTransition:{version:1,rootRevision:-1,before:{undo:[],redo:[]},after:{undo:[],redo:[]},entries:[]}}});assert.throws(()=>resourcePins(snapshot,ledger,registry,new Map()),/invalid_history_transition/);
|
||||
});
|
||||
|
||||
|
||||
// 同一缓存历史范围配合不同 registry 扫描时,分别使用当前 image.resourceFields,不能沿用上次 registry 的资源提取结果。
|
||||
test('cached immutable transition ranges still use each current registry resource definition',()=>{
|
||||
const element={...node('historical','first'),data:{...node('historical','first').data,otherAsset:'second'}},entry={id:'h',label:'h',changes:[{kind:'element',id:element.id,before:null,after:element}],operationIds:[]};
|
||||
const transition=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])};
|
||||
|
||||
Reference in New Issue
Block a user