diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index bed9909b..51d42c82 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -47,7 +47,15 @@ export interface SyncMeta { export interface BlobCacheEntry { /** Relative URL, e.g. "/api/files/42/download" */ url: string; + /** + * Trip this blob belongs to, so it is evicted together with the trip in + * clearTripData. Legacy rows cached before v3 carry the sentinel -1. + */ + tripId: number; blob: Blob; + /** Byte size captured at insert time — Blob.size is not reliably preserved + * across IndexedDB round-trips, so the LRU budget reads this instead. */ + bytes: number; mime: string; cachedAt: number; } @@ -121,6 +129,17 @@ class TrekOfflineDb extends Dexie { tags: 'id', categories: 'id', }); + + // v3: scope the blob cache by trip so it can be evicted with the trip and + // bounded by an LRU budget (see enforceBlobBudget). + this.version(3).stores({ + blobCache: 'url, cachedAt, tripId', + }).upgrade(async (tx) => { + await tx.table('blobCache').toCollection().modify((row: Partial) => { + if (row.tripId == null) row.tripId = -1; + if (row.bytes == null) row.bytes = row.blob?.size ?? 0; + }); + }); } } @@ -245,6 +264,40 @@ export async function getCachedBlob(url: string): Promise { } } +// ── Blob-cache budget ─────────────────────────────────────────────────────── + +/** + * Upper bounds for the offline file-blob cache. Kept conservative so trip + * documents never starve the map-tile cache (sized at MAX_TILES in + * tilePrefetcher.ts) for the origin's storage quota. + */ +export const BLOB_CACHE_MAX_ENTRIES = 200; +export const BLOB_CACHE_MAX_BYTES = 100 * 1024 * 1024; // 100 MB + +/** + * Evict oldest-by-cachedAt blobs until the cache is under both the entry-count + * and byte budget. Call after inserting new blobs. LRU on insertion time, which + * is a reasonable proxy for access for write-once document blobs. + */ +export async function enforceBlobBudget( + maxCount = BLOB_CACHE_MAX_ENTRIES, + maxBytes = BLOB_CACHE_MAX_BYTES, +): Promise { + const entries = await offlineDb.blobCache.orderBy('cachedAt').toArray(); + let count = entries.length; + let totalBytes = entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0); + if (count <= maxCount && totalBytes <= maxBytes) return; + + const toDelete: string[] = []; + for (const e of entries) { + if (count <= maxCount && totalBytes <= maxBytes) break; + toDelete.push(e.url); + totalBytes -= e.bytes ?? 0; + count -= 1; + } + if (toDelete.length) await offlineDb.blobCache.bulkDelete(toDelete); +} + // ── Eviction / cleanup ──────────────────────────────────────────────────────── /** Delete all cached data for one trip (eviction or explicit clear). */ @@ -263,6 +316,7 @@ export async function clearTripData(tripId: number): Promise { offlineDb.tripMembers, offlineDb.mutationQueue, offlineDb.syncMeta, + offlineDb.blobCache, ], async () => { await offlineDb.days.where('trip_id').equals(tripId).delete(); @@ -276,6 +330,7 @@ export async function clearTripData(tripId: number): Promise { await offlineDb.tripMembers.where('tripId').equals(tripId).delete(); await offlineDb.mutationQueue.where('tripId').equals(tripId).delete(); await offlineDb.syncMeta.where('tripId').equals(tripId).delete(); + await offlineDb.blobCache.where('tripId').equals(tripId).delete(); }, ); // Remove the trip row itself outside the transaction since it's a separate table diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index a4662b25..0a0e77e5 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -27,6 +27,7 @@ import { upsertCategories, upsertSyncMeta, clearTripData, + enforceBlobBudget, } from '../db/offlineDb' import { prefetchTilesForTrip } from './tilePrefetcher' import { isAuthed } from './authGate' @@ -109,13 +110,16 @@ async function cacheFilesForTrip(files: TripFile[]): Promise { const resp = await fetch(file.url!, { credentials: 'include' }) if (!resp.ok) continue const blob = await resp.blob() - await offlineDb.blobCache.put({ url: file.url!, blob, mime: file.mime_type, cachedAt: Date.now() }) + await offlineDb.blobCache.put({ url: file.url!, tripId: file.trip_id, blob, bytes: blob.size, mime: file.mime_type, cachedAt: Date.now() }) cached++ } catch { // Network failure — skip this file, will retry next sync } } + // Keep the blob cache within its size/count budget after adding new files. + if (cached > 0) await enforceBlobBudget().catch(() => {}) + // Update filesCachedCount in syncMeta const tripId = files[0]?.trip_id if (tripId) { diff --git a/client/tests/unit/db/offlineDb.test.ts b/client/tests/unit/db/offlineDb.test.ts index d6422fac..1d130a72 100644 --- a/client/tests/unit/db/offlineDb.test.ts +++ b/client/tests/unit/db/offlineDb.test.ts @@ -26,6 +26,7 @@ import { reopenForUser, reopenAnonymous, deleteCurrentUserDb, + enforceBlobBudget, type QueuedMutation, type SyncMeta, type BlobCacheEntry, @@ -84,6 +85,15 @@ const makePlace = (id: number, tripId = 1): Place => ({ created_at: '2026-01-01T00:00:00Z', }); +const makeBlob = (url: string, tripId = 1, bytes = 10, cachedAt = 1): BlobCacheEntry => ({ + url, + tripId, + blob: new Blob(['x'.repeat(bytes)], { type: 'application/pdf' }), + bytes, + mime: 'application/pdf', + cachedAt, +}); + // ── Lifecycle ───────────────────────────────────────────────────────────────── beforeEach(async () => { @@ -223,7 +233,9 @@ describe('offlineDb — blobCache', () => { const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' }); const entry: BlobCacheEntry = { url: '/api/files/99/download', + tripId: 1, blob, + bytes: blob.size, mime: 'application/pdf', cachedAt: Date.now(), }; @@ -234,6 +246,49 @@ describe('offlineDb — blobCache', () => { expect(stored!.mime).toBe('application/pdf'); expect(stored!.blob).toBeDefined(); }); + + it('queries blobs by tripId index', async () => { + await offlineDb.blobCache.bulkPut([ + makeBlob('/api/files/1/download', 1), + makeBlob('/api/files/2/download', 1), + makeBlob('/api/files/3/download', 2), + ]); + const trip1 = await offlineDb.blobCache.where('tripId').equals(1).toArray(); + expect(trip1).toHaveLength(2); + }); +}); + +describe('offlineDb — enforceBlobBudget', () => { + it('evicts oldest-by-cachedAt entries past the count budget', async () => { + // 5 entries with strictly increasing cachedAt; cap to 3. + for (let i = 0; i < 5; i++) { + await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 10, i + 1)); + } + await enforceBlobBudget(3, Infinity); + + expect(await offlineDb.blobCache.count()).toBe(3); + // Oldest two (cachedAt 1 and 2) are gone; newest survive. + expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined(); + expect(await offlineDb.blobCache.get('/api/files/1/download')).toBeUndefined(); + expect(await offlineDb.blobCache.get('/api/files/4/download')).toBeDefined(); + }); + + it('evicts oldest entries past the byte budget', async () => { + // 3 entries of 100 bytes each; cap to 250 bytes → newest two (200) survive. + for (let i = 0; i < 3; i++) { + await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 100, i + 1)); + } + await enforceBlobBudget(Infinity, 250); + + expect(await offlineDb.blobCache.count()).toBe(2); + expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined(); + }); + + it('is a no-op when already within budget', async () => { + await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1)); + await enforceBlobBudget(10, Infinity); + expect(await offlineDb.blobCache.count()).toBe(1); + }); }); describe('offlineDb — clearTripData', () => { @@ -244,9 +299,12 @@ describe('offlineDb — clearTripData', () => { const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, sort_order: 0, quantity: 1 }; await upsertPackingItems([item]); + await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1)); + // Also add data for a different trip — should NOT be removed await upsertTrip(makeTrip(2)); await upsertDays([makeDay(99, 2)]); + await offlineDb.blobCache.put(makeBlob('/api/files/2/download', 2)); await clearTripData(1); @@ -254,10 +312,12 @@ describe('offlineDb — clearTripData', () => { expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0); expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0); expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0); + expect(await offlineDb.blobCache.where('tripId').equals(1).count()).toBe(0); // Trip 2 intact expect(await offlineDb.trips.get(2)).toBeDefined(); expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1); + expect(await offlineDb.blobCache.get('/api/files/2/download')).toBeDefined(); }); });