import { Redis } from "ioredis"; import { logger } from "./logger.js"; const REDIS_URL = process.env["REDIS_URL"] ?? ( process.env["NODE_ENV"] !== "production" ? (console.warn("[env] REDIS_URL not set, using localhost fallback"), "redis://localhost:6380") : (() => { throw new Error("REDIS_URL required in production"); })() ); const KEY_PREFIX = "dashboard:"; const DEFAULT_TTL_SECONDS = 60; let redis: Redis | null = null; function getRedis(): Redis { if (!redis) { redis = new Redis(REDIS_URL, { lazyConnect: false, enableReadyCheck: false, // Don't let cache operations block the app if Redis is slow commandTimeout: 2000, }); redis.on("error", (e: unknown) => { logger.warn({ err: e, redisUrl: REDIS_URL }, "Redis cache connection emitted an error"); }); } return redis; } /** * Retrieve a cached value by key. * Returns null on cache miss or if Redis is unavailable. */ export async function cacheGet(key: string): Promise { try { const raw = await getRedis().get(`${KEY_PREFIX}${key}`); if (raw === null) return null; return JSON.parse(raw) as T; } catch { // Redis down or parse error — fall through to DB return null; } } /** * Store a value in the cache with a TTL. * Silently ignores errors when Redis is unavailable. */ export async function cacheSet( key: string, value: unknown, ttlSeconds: number = DEFAULT_TTL_SECONDS, ): Promise { try { await getRedis().set( `${KEY_PREFIX}${key}`, JSON.stringify(value), "EX", ttlSeconds, ); } catch { // Redis down — silently ignore, data will be served from DB next time } } /** * Delete all keys matching a glob pattern (e.g. "dashboard:*"). * The pattern is automatically prefixed with the KEY_PREFIX unless it already starts with it. */ export async function cacheInvalidate(pattern: string): Promise { try { const fullPattern = pattern.startsWith(KEY_PREFIX) ? pattern : `${KEY_PREFIX}${pattern}`; const r = getRedis(); let cursor = "0"; do { const [nextCursor, keys] = await r.scan( cursor, "MATCH", fullPattern, "COUNT", 100, ); cursor = nextCursor; if (keys.length > 0) { await r.del(...keys); } } while (cursor !== "0"); } catch { // Redis down — nothing to invalidate } } /** * Invalidate all dashboard cache entries. * Convenience wrapper used from mutation hooks. */ export async function invalidateDashboardCache(): Promise { await cacheInvalidate("*"); }