import { afterEach, describe, it, expect, vi } from 'vitest'; import { createSingleFlightCache } from '../src/single-flight-cache'; /** A promise whose resolve/reject is exposed, for driving single-flight races. */ function deferred() { let resolve!: (v: T) => void; let reject!: (e: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } describe('SingleFlightCache', () => { afterEach(() => { vi.useRealTimers(); }); it('disposes retained values and rejects new reads without restarting loads or timers', async () => { vi.useFakeTimers(); const load = vi.fn(async (key: string) => ({ key })); const cache = createSingleFlightCache({ ttlMs: 300_000, load }); await cache.get('first'); await cache.get('second'); expect(vi.getTimerCount()).toBe(1); cache.dispose(); cache.dispose(); cache.clear(); expect(cache.size).toBe(0); expect(vi.getTimerCount()).toBe(0); await expect(cache.get('first')).rejects.toThrow('disposed'); await expect(cache.get('new', { force: true })).rejects.toThrow('disposed'); expect(load).toHaveBeenCalledTimes(2); }); it.each(['resolve', 'reject'] as const)( 'preserves a pending %s after disposal without retaining the result', async (outcome) => { vi.useFakeTimers(); const pending = deferred(); const cache = createSingleFlightCache({ ttlMs: 300_000, load: () => pending.promise }); const first = cache.get('key'); expect(cache.get('key')).toBe(first); cache.dispose(); if (outcome === 'resolve') { pending.resolve('late value'); await expect(first).resolves.toBe('late value'); } else { pending.reject(new Error('late failure')); await expect(first).rejects.toThrow('late failure'); } expect(cache.size).toBe(0); expect(vi.getTimerCount()).toBe(0); }, ); it('releases expired values while idle without expiring newer values early', async () => { vi.useFakeTimers(); const load = vi.fn(async (key: string) => ({ key })); const cache = createSingleFlightCache({ ttlMs: 1000, load }); await cache.get('old'); await vi.advanceTimersByTimeAsync(500); const newer = await cache.get('new'); expect(cache.size).toBe(2); await vi.advanceTimersByTimeAsync(500); expect(cache.size).toBe(1); expect(await cache.get('new')).toBe(newer); expect(load).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(500); expect(cache.size).toBe(0); expect(vi.getTimerCount()).toBe(0); }); it('keeps a refreshed entry until its new deadline and expires other keys', async () => { vi.useFakeTimers(); const cache = createSingleFlightCache({ ttlMs: 1000, load: async (key: string) => key }); await cache.get('refreshed'); await cache.get('old'); await vi.advanceTimersByTimeAsync(500); await cache.get('refreshed', { force: true }); await vi.advanceTimersByTimeAsync(500); expect(cache.size).toBe(1); await vi.advanceTimersByTimeAsync(500); expect(cache.size).toBe(0); }); it.each(['invalidate', 'clear'] as const)( '%s cancels idle cleanup when the last value is removed', async (operation) => { vi.useFakeTimers(); const cache = createSingleFlightCache({ ttlMs: 1000, load: async () => 'value' }); await cache.get('key'); expect(vi.getTimerCount()).toBe(1); cache[operation]('key'); expect(cache.size).toBe(0); expect(vi.getTimerCount()).toBe(0); await cache.get('key'); await vi.advanceTimersByTimeAsync(1000); expect(cache.size).toBe(0); }, ); it('does not let expiration detach a pending refresh', async () => { vi.useFakeTimers(); const pending = deferred(); const load = vi.fn().mockResolvedValueOnce('old').mockReturnValue(pending.promise); const cache = createSingleFlightCache({ ttlMs: 1000, load }); await cache.get('key'); const refresh = cache.get('key', { force: true }); await vi.advanceTimersByTimeAsync(1000); expect(cache.size).toBe(0); expect(cache.get('key')).toBe(refresh); pending.resolve('new'); await expect(refresh).resolves.toBe('new'); expect(cache.size).toBe(1); expect(load).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(1000); expect(cache.size).toBe(0); }); it('does not retain values when caching is disabled by a zero TTL', async () => { vi.useFakeTimers(); const cache = createSingleFlightCache({ ttlMs: 0, load: async () => 'value' }); await expect(cache.get('key')).resolves.toBe('value'); expect(cache.size).toBe(0); expect(vi.getTimerCount()).toBe(0); }); it.each(['invalidate', 'clear'] as const)( '%s detaches pending loads from the cache', async (operation) => { const old = deferred(); const current = deferred(); const load = vi .fn() .mockReturnValueOnce(old.promise) .mockReturnValueOnce(current.promise); const cache = createSingleFlightCache({ ttlMs: 1000, load }); const first = cache.get('key'); cache[operation]('key'); const second = cache.get('key'); expect(load).toHaveBeenCalledTimes(2); old.resolve('old'); await expect(first).resolves.toBe('old'); expect(cache.get('key')).toBe(second); current.resolve('current'); await expect(second).resolves.toBe('current'); await expect(cache.get('key')).resolves.toBe('current'); expect(load).toHaveBeenCalledTimes(2); }, ); it('does not let a late invalidated result overwrite a newer value', async () => { const old = deferred(); const load = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue('current'); const cache = createSingleFlightCache({ ttlMs: 1000, load }); const first = cache.get('key'); cache.invalidate('key'); await expect(cache.get('key')).resolves.toBe('current'); old.resolve('old'); await first; await expect(cache.get('key')).resolves.toBe('current'); }); it('caches within the TTL and reloads once expired', async () => { let clock = 1000; const load = vi.fn(async (k: string) => `${k}@${clock}`); const cache = createSingleFlightCache({ ttlMs: 100, load, now: () => clock, }); expect(await cache.get('a')).toBe('a@1000'); clock = 1050; // still fresh (< 100) expect(await cache.get('a')).toBe('a@1000'); expect(load).toHaveBeenCalledTimes(1); clock = 1101; // 101ms since store → expired expect(await cache.get('a')).toBe('a@1101'); expect(load).toHaveBeenCalledTimes(2); }); it('de-dupes concurrent callers onto a single load (single-flight)', async () => { const d = deferred(); const load = vi.fn(() => d.promise); const cache = createSingleFlightCache({ ttlMs: 1000, load }); const p1 = cache.get('k'); const p2 = cache.get('k'); const p3 = cache.get('k'); expect(load).toHaveBeenCalledTimes(1); // only one load in flight d.resolve('v'); expect(await Promise.all([p1, p2, p3])).toEqual(['v', 'v', 'v']); expect(load).toHaveBeenCalledTimes(1); }); it('keys independently — different keys load separately', async () => { const load = vi.fn(async (k: number) => k * 2); const cache = createSingleFlightCache({ ttlMs: 1000, load }); expect(await cache.get(1)).toBe(2); expect(await cache.get(2)).toBe(4); expect(load).toHaveBeenCalledTimes(2); }); it('force bypasses the freshness check but still joins an in-flight load', async () => { let clock = 0; const load = vi.fn(async (k: string) => `${k}@${clock}`); const cache = createSingleFlightCache({ ttlMs: 10_000, load, now: () => clock, }); expect(await cache.get('a')).toBe('a@0'); // 1 load, cached fresh clock = 5; // still fresh expect(await cache.get('a', { force: true })).toBe('a@5'); // force → reload despite fresh expect(load).toHaveBeenCalledTimes(2); // force still single-flights: two concurrent forced gets share one load. const d = deferred(); load.mockImplementationOnce(() => d.promise); const p1 = cache.get('a', { force: true }); const p2 = cache.get('a', { force: true }); expect(load).toHaveBeenCalledTimes(3); // not 4 d.resolve('shared'); expect(await Promise.all([p1, p2])).toEqual(['shared', 'shared']); }); it('shouldCache=false leaves the entry uncached (next call reloads) but still de-dupes in-flight', async () => { let n = 0; const load = vi.fn(async () => ({ error: true as boolean, n: ++n })); const cache = createSingleFlightCache({ ttlMs: 10_000, load, shouldCache: (v) => !v.error, }); // concurrent callers still share one load... const [a, b] = await Promise.all([cache.get('k'), cache.get('k')]); expect(a).toEqual({ error: true, n: 1 }); expect(b).toEqual({ error: true, n: 1 }); expect(load).toHaveBeenCalledTimes(1); // ...but nothing was cached, so the next call reloads. expect(await cache.get('k')).toEqual({ error: true, n: 2 }); expect(load).toHaveBeenCalledTimes(2); // a good result IS cached. load.mockImplementationOnce(async () => ({ error: false, n: ++n })); const good = await cache.get('k'); expect(good).toEqual({ error: false, n: 3 }); expect(await cache.get('k')).toEqual({ error: false, n: 3 }); // served from cache expect(load).toHaveBeenCalledTimes(3); }); it('a rejected load propagates to all joined callers, is not cached, and clears in-flight', async () => { let attempt = 0; const load = vi.fn(async () => { attempt++; if (attempt === 1) { throw new Error('boom'); } return 'ok'; }); const cache = createSingleFlightCache({ ttlMs: 10_000, load }); const p1 = cache.get('k'); const p2 = cache.get('k'); await expect(p1).rejects.toThrow('boom'); await expect(p2).rejects.toThrow('boom'); // same in-flight, same rejection expect(load).toHaveBeenCalledTimes(1); // in-flight cleared + not cached → next call retries and succeeds. expect(await cache.get('k')).toBe('ok'); expect(load).toHaveBeenCalledTimes(2); }); it('invalidate drops a cached entry; clear drops all', async () => { const load = vi.fn(async (k: string) => k.toUpperCase()); const cache = createSingleFlightCache({ ttlMs: 10_000, load }); await cache.get('a'); await cache.get('b'); expect(load).toHaveBeenCalledTimes(2); cache.invalidate('a'); await cache.get('a'); // reloads await cache.get('b'); // still cached expect(load).toHaveBeenCalledTimes(3); cache.clear(); await cache.get('a'); await cache.get('b'); expect(load).toHaveBeenCalledTimes(5); }); it('allows retry when a loader throws before returning its promise', async () => { const load = vi .fn<() => Promise>() .mockImplementationOnce(() => { throw new Error('failed before request'); }) .mockResolvedValue('recovered'); const cache = createSingleFlightCache({ ttlMs: 1000, load }); await expect(cache.get('key')).rejects.toThrow('failed before request'); await expect(cache.get('key')).resolves.toBe('recovered'); expect(load).toHaveBeenCalledTimes(2); }); });