Service Worker v25: releases no longer delete offline map tiles

Tile cache names included CACHE_VERSION, and activation deletes caches
whose names are not current, so every release threw away every tile
officers had downloaded for field work.

Tile caches now carry their own TILE_CACHE_VERSION, frozen at v24 so
devices on v24 keep their tiles untouched. Tiles in any older tile cache
(including the legacy tiles-v1 bucket) are moved into the current
per-provider caches instead of deleted: nothing current is overwritten,
limits are respected, and the old cache is removed only after a
successful copy. Keyless Carto and Google tiles are not carried over.
Old shell/modules/api caches are still deleted, and clients are claimed
before the tile move so the page is not held up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ekke 2026-09-17 15:00:52 +02:00
parent 486480eda6
commit 26c8438e04
2 changed files with 210 additions and 66 deletions

124
dist/sw.js vendored
View File

@ -137,7 +137,13 @@
// Mercator metres, which overstated them by 1/cos(latitude) — about 3 m on
// a 200 m circle at Wa. The Circle and Area analysis popups can be dragged
// by their title bar, staying attached to the shape they describe.
const CACHE_VERSION = 'v24';
// v25: Releases no longer delete offline map tiles. Tile cache names used to
// include CACHE_VERSION, and activation deletes caches whose names are not
// current, so every release since the tile caches were split per provider
// threw away every tile officers had downloaded for field work. Tile caches
// now carry their own TILE_CACHE_VERSION, frozen at v24, and tiles in any
// older tile cache are moved into the current ones instead of deleted.
const CACHE_VERSION = 'v25';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
@ -147,11 +153,24 @@ const API_CACHE = `api-${CACHE_VERSION}`;
// Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile,
// which covers a Ghana district at zoom 10–15 (typical field-work range).
// ----------------------------------------------------------------------------
const TILES_OSM = `tiles-osm-${CACHE_VERSION}`;
const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`;
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
//
// Tile caches are versioned separately from the app. They used to take their
// name from CACHE_VERSION, and the activate handler deletes every tiles-* cache
// whose name is not current — so each release silently threw away every tile an
// officer had downloaded for offline field work, for every provider. Map tiles
// do not change between releases of the app, so their cache names no longer do.
//
// TILE_CACHE_VERSION is frozen at the value CACHE_VERSION had when that stopped,
// so devices already on v24 keep their existing tile caches with nothing to copy.
// Change it only if the way tiles are stored changes — and even then, activate
// moves tiles from older tile caches into the new ones rather than deleting them
// (see migrateTileCache).
const TILE_CACHE_VERSION = 'v24';
const TILES_OSM = `tiles-osm-${TILE_CACHE_VERSION}`;
const TILES_TOPO = `tiles-topo-${TILE_CACHE_VERSION}`;
const TILES_SATELLITE = `tiles-satellite-${TILE_CACHE_VERSION}`;
const TILES_CARTO_LIGHT = `tiles-carto-light-${TILE_CACHE_VERSION}`;
const TILES_CARTO_DARK = `tiles-carto-dark-${TILE_CACHE_VERSION}`;
// Per-provider tile limits.
// • OSM and Topographic are the providers offered for active offline
@ -228,36 +247,89 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
// Build the set of caches that should remain
event.waitUntil((async () => {
const cacheNames = await caches.keys();
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
return Promise.all(
// App caches from earlier releases hold nothing worth keeping: delete them.
await Promise.all(
cacheNames
// Delete anything that:
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
// • but is NOT in the current keep set
// This includes the legacy "tiles-v1" single bucket.
.filter((name) => {
const isOurs =
name.startsWith('shell-') ||
name.startsWith('tiles-') ||
name.startsWith('modules-') ||
name.startsWith('api-');
return isOurs && !keep.has(name);
})
.filter((name) =>
(name.startsWith('shell-') || name.startsWith('modules-') || name.startsWith('api-'))
&& !keep.has(name))
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
// Take control before moving tiles, so the page is not held waiting on a
// copy that can run to thousands of entries.
await self.clients.claim();
// Tile caches from earlier releases hold what officers downloaded for
// offline use: move those tiles into the current caches, then delete. This
// includes the legacy single "tiles-v1" bucket, whose tiles are sorted into
// the right provider by URL.
const oldTileCaches = cacheNames.filter((name) => name.startsWith('tiles-') && !keep.has(name));
for (const name of oldTileCaches) {
await migrateTileCache(name);
}
})());
});
/**
* Move the tiles in a tile cache from an earlier release into the current
* per-provider caches, then delete it.
*
* Each tile goes where getTileCacheName() would put it today, so the legacy
* single bucket is sorted by provider too. Nothing already in the current cache
* is overwritten, and no cache is filled past its limit. If the copy fails part
* way, the old cache is left in place rather than deleted, so its tiles are not
* lost and the next activation carries on from there.
*/
async function migrateTileCache(oldName) {
try {
const oldCache = await caches.open(oldName);
const requests = await oldCache.keys();
const targets = new Map(); // cacheName -> { cache, count }
let moved = 0;
let skipped = 0;
for (const request of requests) {
const url = new URL(request.url);
const targetName = getTileCacheName(url);
// A provider no longer cached, or a Carto tile requested without a key.
// The app has sent the CARTO key on every tile request since August 2026,
// so a keyless Carto URL is never requested again — and anything cached
// after CARTO began enforcing keys carries its "API KEY REQUIRED" stamp.
const deadCarto = url.hostname.endsWith('basemaps.cartocdn.com') && !url.searchParams.has('key');
if (!targetName || deadCarto) { skipped++; continue; }
let target = targets.get(targetName);
if (!target) {
const cache = await caches.open(targetName);
target = { cache, count: (await cache.keys()).length };
targets.set(targetName, target);
}
if (target.count >= (TILE_LIMITS[targetName] || 1500)) { skipped++; continue; }
if (await target.cache.match(request)) { skipped++; continue; }
const response = await oldCache.match(request);
if (!response) { skipped++; continue; }
await target.cache.put(request, response);
target.count++;
moved++;
}
await caches.delete(oldName);
console.log(`[SW] Moved ${moved} tiles out of ${oldName} (${skipped} not needed) and removed it`);
} catch (err) {
console.warn(`[SW] Could not move tiles out of ${oldName}; kept it for the next activation:`, err);
}
}
// ============================================================================
// FETCH EVENT
// ============================================================================

View File

@ -137,7 +137,13 @@
// Mercator metres, which overstated them by 1/cos(latitude) — about 3 m on
// a 200 m circle at Wa. The Circle and Area analysis popups can be dragged
// by their title bar, staying attached to the shape they describe.
const CACHE_VERSION = 'v24';
// v25: Releases no longer delete offline map tiles. Tile cache names used to
// include CACHE_VERSION, and activation deletes caches whose names are not
// current, so every release since the tile caches were split per provider
// threw away every tile officers had downloaded for field work. Tile caches
// now carry their own TILE_CACHE_VERSION, frozen at v24, and tiles in any
// older tile cache are moved into the current ones instead of deleted.
const CACHE_VERSION = 'v25';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
@ -147,11 +153,24 @@ const API_CACHE = `api-${CACHE_VERSION}`;
// Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile,
// which covers a Ghana district at zoom 10–15 (typical field-work range).
// ----------------------------------------------------------------------------
const TILES_OSM = `tiles-osm-${CACHE_VERSION}`;
const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`;
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
//
// Tile caches are versioned separately from the app. They used to take their
// name from CACHE_VERSION, and the activate handler deletes every tiles-* cache
// whose name is not current — so each release silently threw away every tile an
// officer had downloaded for offline field work, for every provider. Map tiles
// do not change between releases of the app, so their cache names no longer do.
//
// TILE_CACHE_VERSION is frozen at the value CACHE_VERSION had when that stopped,
// so devices already on v24 keep their existing tile caches with nothing to copy.
// Change it only if the way tiles are stored changes — and even then, activate
// moves tiles from older tile caches into the new ones rather than deleting them
// (see migrateTileCache).
const TILE_CACHE_VERSION = 'v24';
const TILES_OSM = `tiles-osm-${TILE_CACHE_VERSION}`;
const TILES_TOPO = `tiles-topo-${TILE_CACHE_VERSION}`;
const TILES_SATELLITE = `tiles-satellite-${TILE_CACHE_VERSION}`;
const TILES_CARTO_LIGHT = `tiles-carto-light-${TILE_CACHE_VERSION}`;
const TILES_CARTO_DARK = `tiles-carto-dark-${TILE_CACHE_VERSION}`;
// Per-provider tile limits.
// • OSM and Topographic are the providers offered for active offline
@ -228,36 +247,89 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
// Build the set of caches that should remain
event.waitUntil((async () => {
const cacheNames = await caches.keys();
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
return Promise.all(
// App caches from earlier releases hold nothing worth keeping: delete them.
await Promise.all(
cacheNames
// Delete anything that:
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
// • but is NOT in the current keep set
// This includes the legacy "tiles-v1" single bucket.
.filter((name) => {
const isOurs =
name.startsWith('shell-') ||
name.startsWith('tiles-') ||
name.startsWith('modules-') ||
name.startsWith('api-');
return isOurs && !keep.has(name);
})
.filter((name) =>
(name.startsWith('shell-') || name.startsWith('modules-') || name.startsWith('api-'))
&& !keep.has(name))
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
// Take control before moving tiles, so the page is not held waiting on a
// copy that can run to thousands of entries.
await self.clients.claim();
// Tile caches from earlier releases hold what officers downloaded for
// offline use: move those tiles into the current caches, then delete. This
// includes the legacy single "tiles-v1" bucket, whose tiles are sorted into
// the right provider by URL.
const oldTileCaches = cacheNames.filter((name) => name.startsWith('tiles-') && !keep.has(name));
for (const name of oldTileCaches) {
await migrateTileCache(name);
}
})());
});
/**
* Move the tiles in a tile cache from an earlier release into the current
* per-provider caches, then delete it.
*
* Each tile goes where getTileCacheName() would put it today, so the legacy
* single bucket is sorted by provider too. Nothing already in the current cache
* is overwritten, and no cache is filled past its limit. If the copy fails part
* way, the old cache is left in place rather than deleted, so its tiles are not
* lost and the next activation carries on from there.
*/
async function migrateTileCache(oldName) {
try {
const oldCache = await caches.open(oldName);
const requests = await oldCache.keys();
const targets = new Map(); // cacheName -> { cache, count }
let moved = 0;
let skipped = 0;
for (const request of requests) {
const url = new URL(request.url);
const targetName = getTileCacheName(url);
// A provider no longer cached, or a Carto tile requested without a key.
// The app has sent the CARTO key on every tile request since August 2026,
// so a keyless Carto URL is never requested again — and anything cached
// after CARTO began enforcing keys carries its "API KEY REQUIRED" stamp.
const deadCarto = url.hostname.endsWith('basemaps.cartocdn.com') && !url.searchParams.has('key');
if (!targetName || deadCarto) { skipped++; continue; }
let target = targets.get(targetName);
if (!target) {
const cache = await caches.open(targetName);
target = { cache, count: (await cache.keys()).length };
targets.set(targetName, target);
}
if (target.count >= (TILE_LIMITS[targetName] || 1500)) { skipped++; continue; }
if (await target.cache.match(request)) { skipped++; continue; }
const response = await oldCache.match(request);
if (!response) { skipped++; continue; }
await target.cache.put(request, response);
target.count++;
moved++;
}
await caches.delete(oldName);
console.log(`[SW] Moved ${moved} tiles out of ${oldName} (${skipped} not needed) and removed it`);
} catch (err) {
console.warn(`[SW] Could not move tiles out of ${oldName}; kept it for the next activation:`, err);
}
}
// ============================================================================
// FETCH EVENT
// ============================================================================