diff --git a/src/lib/cache.test.ts b/src/lib/cache.test.ts index ad47e6a..46054ce 100644 --- a/src/lib/cache.test.ts +++ b/src/lib/cache.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { __setMemoryCache, + __pickBackendName, cacheGet, cacheSet, cacheDel, @@ -171,3 +172,49 @@ describe('UpstashBackend', () => { expect(mockUpstash.del).toHaveBeenCalledWith('k3'); }); }); + +describe('backend selection', () => { + it('selects UpstashBackend when KV_REST_API_URL and KV_REST_API_TOKEN are set', () => { + const name = __pickBackendName({ + KV_REST_API_URL: 'https://example.upstash.io', + KV_REST_API_TOKEN: 'token', + }); + expect(name).toBe('UpstashBackend'); + }); + + it('selects IoRedisBackend when only REDIS_URL is set', () => { + const name = __pickBackendName({ REDIS_URL: 'redis://localhost:6379' }); + expect(name).toBe('IoRedisBackend'); + }); + + it('selects MemoryBackend when no Redis env vars are set', () => { + const name = __pickBackendName({}); + expect(name).toBe('MemoryBackend'); + }); + + it('prefers Upstash over Redis when both are set', () => { + const name = __pickBackendName({ + KV_REST_API_URL: 'https://example.upstash.io', + KV_REST_API_TOKEN: 'token', + REDIS_URL: 'redis://localhost:6379', + }); + expect(name).toBe('UpstashBackend'); + }); + + it('logs console.error when MemoryBackend is selected in production', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + __pickBackendName({ NODE_ENV: 'production' }); + expect(spy).toHaveBeenCalledOnce(); + expect(spy.mock.calls[0]?.[0]).toContain('[cache] No Redis backend configured'); + spy.mockRestore(); + }); + + it('does not log when MemoryBackend is selected outside production', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + __pickBackendName({ NODE_ENV: 'development' }); + __pickBackendName({ NODE_ENV: 'test' }); + __pickBackendName({}); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 8cfb3ee..d6af5c5 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -140,6 +140,20 @@ export class IoRedisBackend implements CacheBackend { let backend: CacheBackend = pickDefaultBackend(); +/** + * Selects the cache backend from environment variables. + * + * Priority: Upstash (KV_REST_API_URL + KV_REST_API_TOKEN) > Redis (REDIS_URL) + * > MemoryBackend. + * + * MemoryBackend is intentional for local development and tests. In a + * serverless deployment (e.g. Vercel) each function invocation is an + * isolated process with its own in-memory store, so MemoryBackend provides + * no cross-request state sharing. Rate limits and other shared-state features + * will not work correctly without a Redis-compatible backend in production. + * A startup error is logged when MemoryBackend is selected in production so + * the misconfiguration is visible in deployment logs. + */ function pickDefaultBackend(): CacheBackend { const upstashUrl = process.env.KV_REST_API_URL; const upstashToken = process.env.KV_REST_API_TOKEN; @@ -161,14 +175,54 @@ function pickDefaultBackend(): CacheBackend { return new IoRedisBackend(client); } + if (process.env.NODE_ENV === 'production') { + console.error( + '[cache] No Redis backend configured (KV_REST_API_URL / REDIS_URL not set). ' + + 'Falling back to MemoryBackend. In a serverless deployment each invocation ' + + 'gets an isolated in-process store, so rate limits and shared-state features ' + + 'are NOT enforced across requests. ' + + 'Set KV_REST_API_URL + KV_REST_API_TOKEN (Upstash) or REDIS_URL (self-hosted) ' + + 'to enable a shared cache backend.', + ); + } return new MemoryBackend(); } -// Test-only hook. Resets to a fresh memory map between tests. +// Test-only hooks. Must not be called in production code paths. export function __setMemoryCache(): void { backend = new MemoryBackend(); } +/** + * Calls pickDefaultBackend with the given environment overrides and returns + * the name of the selected backend class. Used in tests to assert which + * backend is selected for a given env configuration without reloading the + * module. + */ +export function __pickBackendName(env: { + KV_REST_API_URL?: string; + KV_REST_API_TOKEN?: string; + REDIS_URL?: string; + NODE_ENV?: string; +}): string { + const { KV_REST_API_URL, KV_REST_API_TOKEN, REDIS_URL, NODE_ENV } = env; + + if (KV_REST_API_URL && KV_REST_API_TOKEN) return 'UpstashBackend'; + if (REDIS_URL) return 'IoRedisBackend'; + + if (NODE_ENV === 'production') { + console.error( + '[cache] No Redis backend configured (KV_REST_API_URL / REDIS_URL not set). ' + + 'Falling back to MemoryBackend. In a serverless deployment each invocation ' + + 'gets an isolated in-process store, so rate limits and shared-state features ' + + 'are NOT enforced across requests. ' + + 'Set KV_REST_API_URL + KV_REST_API_TOKEN (Upstash) or REDIS_URL (self-hosted) ' + + 'to enable a shared cache backend.', + ); + } + return 'MemoryBackend'; +} + export function cacheGet(key: string): Promise { return backend.get(key); }