Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions src/__tests__/services/api/cache.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
59 changes: 46 additions & 13 deletions src/__tests__/utils/cacheVersioning.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<typeof AsyncStorage>;
const mockImageCache = ImageCache as jest.Mocked<typeof ImageCache>;
const mockClearCache = jest.mocked(clearCache);

beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -142,14 +142,47 @@ 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']);

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();
});
Expand Down
31 changes: 24 additions & 7 deletions src/services/api/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ interface CacheEntry<T> {
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<string, CacheEntry<unknown>>();
Expand Down Expand Up @@ -32,14 +33,28 @@ export function setCache<T>(
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();
}
Expand All @@ -51,31 +66,33 @@ 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<T>(
key: string,
fetcher: () => Promise<T>,
ttl = 60_000,
staleTtl = 300_000,
dataVersion?: string,
): Promise<T> {
const cached = getCache<T>(key);

if (cached !== null) {
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;
}

// No cache – fetch synchronously
const fresh = await fetcher();
setCache(key, fresh, ttl, staleTtl);
setCache(key, fresh, ttl, staleTtl, dataVersion);
return fresh;
}
3 changes: 3 additions & 0 deletions src/utils/cacheVersioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -82,6 +83,8 @@ export async function handleCacheVersionUpdate(newVersion: string): Promise<bool

if (needsInvalidation) {
logger.info(`Cache invalidated: ${storedVersion ?? 'none'} → ${newVersion}`);
// Clear in-memory API cache immediately (synchronous)
clearCache();
try {
const allKeys = await AsyncStorage.getAllKeys();
const keysToRemove = allKeys.filter(k => k !== CACHE_VERSION_KEY);
Expand Down
Loading