diff --git a/App.tsx b/App.tsx index 79ff64e..70ae326 100644 --- a/App.tsx +++ b/App.tsx @@ -24,6 +24,7 @@ import { requestQueue } from './src/services/requestQueue'; import socketService from './src/services/socket'; import syncService from './src/services/syncService'; import { useAppStore } from './src/store'; +import { handleCacheVersionUpdate } from './src/utils/cacheVersioning'; import { requireEnvVariables } from './src/utils/env'; import { appLogger } from './src/utils/logger'; import { handleNotificationReceived } from './src/utils/notificationHandlers'; @@ -69,6 +70,10 @@ const App = () => { // You can add custom fonts here later if needed }); + // 2. Version-based cache invalidation: clear stale caches on app/data version bump + const appVersion = require('./package.json').version as string; + await handleCacheVersionUpdate(appVersion); + // 2. Check Auth State / wait for store hydration // Zustand persist automatically hydrates, we can assume it's done or add a small delay // to ensure initial data fetching completes. diff --git a/src/__tests__/services/api/cache.test.ts b/src/__tests__/services/api/cache.test.ts new file mode 100644 index 0000000..e577848 --- /dev/null +++ b/src/__tests__/services/api/cache.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for the in-memory cache module (cache.ts). + * No mocks — exercises the real implementation. + */ +import { + clearCache, + getCache, + invalidateCacheByDataVersion, + setCache, +} from '../../../services/api/cache'; + +beforeEach(() => { + clearCache(); +}); + +describe('invalidateCacheByDataVersion', () => { + it('removes entries matching the given data version', () => { + setCache('key:a', 'data-a', 60_000, 300_000, 'v2'); + setCache('key:b', 'data-b', 60_000, 300_000, 'v2'); + setCache('key:c', 'data-c', 60_000, 300_000, 'v1'); + + invalidateCacheByDataVersion('v2'); + + expect(getCache('key:a')).toBeNull(); + expect(getCache('key:b')).toBeNull(); + expect(getCache('key:c')).toBe('data-c'); + }); + + it('leaves all entries intact when no version matches', () => { + setCache('key:x', 'data-x', 60_000, 300_000, 'v3'); + + invalidateCacheByDataVersion('v99'); + + expect(getCache('key:x')).toBe('data-x'); + }); + + it('handles entries with no dataVersion without throwing', () => { + setCache('key:unversioned', 'data', 60_000, 300_000); + + expect(() => invalidateCacheByDataVersion('v1')).not.toThrow(); + expect(getCache('key:unversioned')).toBe('data'); + }); +}); diff --git a/src/__tests__/utils/cacheVersioning.test.ts b/src/__tests__/utils/cacheVersioning.test.ts index 4a33f60..8749ed1 100644 --- a/src/__tests__/utils/cacheVersioning.test.ts +++ b/src/__tests__/utils/cacheVersioning.test.ts @@ -1,11 +1,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; +import { clearCache } from '../../services/api/cache'; import { - parseSemver, - shouldInvalidateCache, getStoredCacheVersion, - setStoredCacheVersion, handleCacheVersionUpdate, + parseSemver, + setStoredCacheVersion, + shouldInvalidateCache, } from '../../utils/cacheVersioning'; import { ImageCache } from '../../utils/imageCache'; @@ -19,23 +20,22 @@ jest.mock('@react-native-async-storage/async-storage', () => ({ })); jest.mock('../../utils/imageCache', () => ({ - ImageCache: { - clearCache: jest.fn(), - }, + ImageCache: { clearCache: jest.fn() }, })); jest.mock('../../utils/logger', () => ({ __esModule: true, - default: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - }, + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('../../services/api/cache', () => ({ + clearCache: jest.fn(), + invalidateCacheByDataVersion: jest.fn(), })); const mockStorage = AsyncStorage as jest.Mocked; const mockImageCache = ImageCache as jest.Mocked; +const mockClearCache = jest.mocked(clearCache); beforeEach(() => { jest.clearAllMocks(); @@ -142,6 +142,40 @@ describe('handleCacheVersionUpdate', () => { expect(mockStorage.setItem).toHaveBeenCalledWith('@teachlink_cache_version', '1.0.0'); }); + it('clears in-memory cache on invalidation', async () => { + mockStorage.getItem.mockResolvedValue('1.0.0'); + mockStorage.getAllKeys.mockResolvedValue(['@teachlink_cache_version', '@teachlink_courses']); + + await handleCacheVersionUpdate('2.0.0'); + + expect(mockClearCache).toHaveBeenCalled(); + }); + + it('does NOT clear in-memory cache when version is preserved', async () => { + mockStorage.getItem.mockResolvedValue('1.0.0'); + + await handleCacheVersionUpdate('1.0.1'); + + expect(mockClearCache).not.toHaveBeenCalled(); + }); + + it('clears in-memory cache before AsyncStorage on invalidation', async () => { + mockStorage.getItem.mockResolvedValue('1.0.0'); + mockStorage.getAllKeys.mockResolvedValue(['@teachlink_courses']); + + const callOrder: string[] = []; + mockClearCache.mockImplementation(() => { + callOrder.push('clearCache'); + }); + mockStorage.multiRemove.mockImplementation(async () => { + callOrder.push('multiRemove'); + }); + + await handleCacheVersionUpdate('2.0.0'); + + expect(callOrder.indexOf('clearCache')).toBeLessThan(callOrder.indexOf('multiRemove')); + }); + it('invalidates cache on major version bump', async () => { mockStorage.getItem.mockResolvedValue('1.0.0'); mockStorage.getAllKeys.mockResolvedValue(['@teachlink_cache_version', '@teachlink_courses']); @@ -149,7 +183,6 @@ describe('handleCacheVersionUpdate', () => { const result = await handleCacheVersionUpdate('2.0.0'); expect(result).toBe(true); - // Should NOT remove the version key itself expect(mockStorage.multiRemove).toHaveBeenCalledWith(['@teachlink_courses']); expect(mockImageCache.clearCache).toHaveBeenCalled(); }); diff --git a/src/services/api/cache.ts b/src/services/api/cache.ts index 73de146..4766a1a 100644 --- a/src/services/api/cache.ts +++ b/src/services/api/cache.ts @@ -3,6 +3,7 @@ interface CacheEntry { cachedAt: number; ttl: number; // ms until stale staleTtl: number; // ms until evicted (stale-while-revalidate window) + dataVersion?: string; // optional server data version tag } const store = new Map>(); @@ -32,14 +33,28 @@ export function setCache( data: T, ttl: number, staleTtl: number, + dataVersion?: string, ): void { - store.set(key, { data, cachedAt: Date.now(), ttl, staleTtl }); + store.set(key, { data, cachedAt: Date.now(), ttl, staleTtl, dataVersion }); } export function invalidateCache(key: string): void { store.delete(key); } +/** + * Removes all in-memory cache entries that were stored with the given + * dataVersion. Useful when the server signals that a specific data version + * is no longer valid. + */ +export function invalidateCacheByDataVersion(version: string): void { + for (const [key, entry] of store) { + if (entry.dataVersion === version) { + store.delete(key); + } + } +} + export function clearCache(): void { store.clear(); } @@ -51,16 +66,18 @@ export function clearCache(): void { * - Triggers a background revalidation when the entry is stale. * - Falls back to a fresh fetch when no cache entry exists. * - * @param key Cache key - * @param fetcher Async function that fetches fresh data - * @param ttl Time (ms) before data is considered stale (default 60 s) - * @param staleTtl Time (ms) before stale data is evicted (default 5 min) + * @param key Cache key + * @param fetcher Async function that fetches fresh data + * @param ttl Time (ms) before data is considered stale (default 60 s) + * @param staleTtl Time (ms) before stale data is evicted (default 5 min) + * @param dataVersion Optional server data version tag for this entry */ export async function fetchWithSWR( key: string, fetcher: () => Promise, ttl = 60_000, staleTtl = 300_000, + dataVersion?: string, ): Promise { const cached = getCache(key); @@ -68,7 +85,7 @@ export async function fetchWithSWR( if (isStaleCache(key)) { // Revalidate in the background; return stale data now fetcher() - .then((fresh) => setCache(key, fresh, ttl, staleTtl)) + .then((fresh) => setCache(key, fresh, ttl, staleTtl, dataVersion)) .catch(() => {/* keep stale data on error */}); } return cached; @@ -76,6 +93,6 @@ export async function fetchWithSWR( // No cache – fetch synchronously const fresh = await fetcher(); - setCache(key, fresh, ttl, staleTtl); + setCache(key, fresh, ttl, staleTtl, dataVersion); return fresh; } diff --git a/src/utils/cacheVersioning.ts b/src/utils/cacheVersioning.ts index 0070728..17e6dd1 100644 --- a/src/utils/cacheVersioning.ts +++ b/src/utils/cacheVersioning.ts @@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { ImageCache } from './imageCache'; import logger from './logger'; // eslint-disable-line import/no-named-as-default +import { clearCache } from '../services/api/cache'; /** * Parses a semver string into its numeric components. @@ -82,6 +83,8 @@ export async function handleCacheVersionUpdate(newVersion: string): Promise k !== CACHE_VERSION_KEY);