52 lines
13 KiB
JavaScript
52 lines
13 KiB
JavaScript
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
|
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;
|
|
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});
|
|
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}];
|
|
const openRaw=()=>new Promise((resolve,reject)=>{const request=indexedDB.open(name);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
|
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);});
|
|
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);});
|
|
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);});
|
|
const rejects=async(fn,code)=>{try{await fn();}catch(error){assert(error.code===code,`expected ${code}, got ${error.code}`);return;}throw Error(`expected ${code}`);};
|
|
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===4,'pointer storage did not upgrade to DB v4');
|
|
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');
|
|
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');
|
|
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');
|
|
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');
|
|
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');
|
|
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');
|
|
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);
|
|
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);}
|
|
}
|
|
|
|
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))));
|
|
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);});
|
|
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===4&&row.fileVersion===1&&!(row instanceof Blob),'old project was not lazily promoted');raw.close();}finally{await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
|
}
|
|
|
|
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());
|
|
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);}};
|
|
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();
|
|
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');
|
|
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);}
|
|
}
|