diff --git a/frontend/asset-sync-core.js b/frontend/asset-sync-core.js index e2b68fb..20671af 100644 --- a/frontend/asset-sync-core.js +++ b/frontend/asset-sync-core.js @@ -33,6 +33,14 @@ return keep; } + function fallback(manifest, previous, path) { + if (!manifest.files[path] || !previous?.files[path]) return null; + const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path)); + if (!group || !previous.groups[group]?.files.includes(path) || + manifest.groups[group].contract !== previous.groups[group].contract) return null; + return url(path, previous.files[path]); + } + async function state(cache) { const response = await cache.match(STATE); return response ? response.json() : null; @@ -47,10 +55,12 @@ return { ready: missing === 0, missing, version: manifest.buildTag }; } - async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout }) { + async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups }) { const previous = (await state(cache))?.current; const missing = []; - for (const path of blocking(manifest, previous, activeLayout)) { + const selected = groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort() + : blocking(manifest, previous, activeLayout); + for (const path of selected) { const key = url(path, manifest.files[path]); const response = await cache.match(key); if (!response || response.headers.get('X-Asset-Hash') !== manifest.files[path].h) missing.push(key); @@ -80,7 +90,7 @@ } })); if (failure) throw failure; - if (!(await status(manifest, cache, { previous, activeLayout })).ready) throw new Error('Incomplete blocking assets'); + if (!groups && !(await status(manifest, cache, { previous, activeLayout })).ready) throw new Error('Incomplete blocking assets'); return { refreshed: count, caches: 1 }; } @@ -106,7 +116,7 @@ return next; } - const api = { CACHE, STATE, url, paths, blocking, plan, retained, status, syncAssets, state, commit }; + const api = { CACHE, STATE, url, paths, blocking, plan, retained, fallback, status, syncAssets, state, commit }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.AssetSyncCore = api; })(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js index a34a871..1e6979a 100644 --- a/frontend/asset-sync-core.test.js +++ b/frontend/asset-sync-core.test.js @@ -10,3 +10,27 @@ test('commit is atomic and pruning keeps only current and previous changed versi test('downloads are capped at six and each failure gets three attempts',async()=>{const c=cache(),files={},list=[];for(let i=0;i<13;i++){const p='/'+i+'.js';files[p]={h:String(i)};list.push(p)}const m={buildTag:'pool',files,groups:{core:{files:list}}};let active=0,max=0;const tries={};await core.syncAssets(m,{cache:c,concurrency:20,fetchFn:async u=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,5));active--;tries[u]=(tries[u]||0)+1;if(tries[u]<3)throw Error('drop');return response(u.split('=')[1])}});assert.equal(max,6);assert.ok(Object.values(tries).every(n=>n===3));}); test('background files do not block, but active layout and contract changes do',()=>{const m=manifest();m.groups.extra={background:true,contract:2,files:['/extra.js']};m.files['/extra.js']={h:'e'};assert.deepEqual(core.blocking(m),['/a.js','/index.html']);assert.ok(core.blocking(m,{groups:{extra:{contract:1}}}).includes('/extra.js'));m.groups['layout:classic']={background:true,files:['/classic.css']};m.files['/classic.css']={h:'c'};assert.ok(core.blocking(m,null,'classic').includes('/classic.css'));}); test('removed files survive one previous build for open tabs',()=>{const previous=manifest(),current=manifest('c');delete current.files['/a.js'];current.groups.core.files=['/index.html'];assert.ok(core.retained(current,previous).has('/a.js?v=a'));}); + +test('staged fallback permits only the previous same-contract version of a shipped group file', () => { + const old = manifest(), next = manifest('c'); + old.groups.core.contract = next.groups.core.contract = 1; + assert.equal(core.fallback(next, old, '/a.js'), '/a.js?v=a'); + next.groups.core.contract = 2; + assert.equal(core.fallback(next, old, '/a.js'), null); + assert.equal(core.fallback(next, old, '/missing.js'), null); +}); + +test('background synchronization fills all groups without publishing a new current manifest', async () => { + const c = cache(), m = manifest(); + m.groups.optional = { contract: 1, background: true, files: ['/extra.js'] }; + m.files['/extra.js'] = { h: 'e' }; + const fetchFn = async key => response(key.split('=')[1]); + await core.syncAssets(m, { cache: c, fetchFn }); + assert.equal(await c.match('/extra.js?v=e'), undefined); + await core.commit(m, c); + const before = await core.state(c); + const result = await core.syncAssets(m, { cache: c, fetchFn, groups: ['optional'] }); + assert.equal(result.refreshed, 1); + assert.ok(await c.match('/extra.js?v=e')); + assert.deepEqual(await core.state(c), before); +});