Skip to content
Closed
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
47 changes: 47 additions & 0 deletions src/lib/cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
__setMemoryCache,
__pickBackendName,
cacheGet,
cacheSet,
cacheDel,
Expand Down Expand Up @@ -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();
});
});
56 changes: 55 additions & 1 deletion src/lib/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<T>(key: string): Promise<T | null> {
return backend.get<T>(key);
}
Expand Down
Loading