Files
Endoleap/tests/headless/depot-files-browser-cases.mjs
T

82 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 浏览器 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');
await storage.commit(p,{image:image(0,[['one',resource('one',a.type,false)],['two',resource('two',b.type,false)]]),files:[{id:'one',bytes:a},{id:'two',bytes:b}],depot:cut(pins)});
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);}
}