75 lines
11 KiB
JavaScript
75 lines
11 KiB
JavaScript
/**
|
|
* 共享资源 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');
|
|
// 先让 depot 排入写入,再由 files 的重复身份触发失败;检查整笔 IDB 事务回滚,而非只测发送前校验拒绝。
|
|
await storage.commit(q,{image:image(2),files:[{id:'existing',bytes}],depot:cut()});
|
|
await rejects(()=>storage.commit(q,{image:image(3),files:[{id:'existing',bytes}],depot:cut([['one',['element:q']]], [{ref:ref('one'),bytes}])}),'resource_identity_conflict');assert(await count()===0&&(await storage.recover(q)).rootRevision===2,'late failure committed depot or Root');
|
|
await storage.commit(q,{image:image(3),depot:cut([['one',['element:q']]], [{ref:ref('one'),bytes}])});assert(await count()===1&&(await storage.assetDepot.read(q,authority,'one')).purgedAt===undefined,'authorized rehydration failed');
|
|
// limit=1 强制分两页;nextAfter 是资源身份游标,第二页结束才表示这一轮扫描完成。
|
|
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');
|
|
// 从手工 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();
|
|
}finally{for(const leader of leaders)await leader.release();storage.close();indexedDB.deleteDatabase(name);}
|
|
}
|