TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import Redis from "ioredis";2import { config } from "./config";34/**5 * Redis client with an in-memory fallback for local development. Production requires6 * REDIS_URL — the fallback logs a warning and only works within a single process.7 */8export interface KV {9 incrWithTtl(key: string, ttlSec: number): Promise<number>;10 decr(key: string): Promise<number>;11 get(key: string): Promise<string | null>;12 set(key: string, value: string, ttlSec?: number): Promise<void>;13 del(key: string): Promise<void>;14 /** Sliding-window counter: returns count within window after adding one hit. */15 slidingWindow(key: string, windowMs: number, limit: number): Promise<{ count: number; allowed: boolean; retryAfterMs: number }>;16 ping(): Promise<boolean>;17 kind: "redis" | "memory";18}1920class MemoryKV implements KV {21 kind = "memory" as const;22 private store = new Map<string, { v: string; exp: number | null }>();23 private windows = new Map<string, number[]>();2425 private alive(k: string) {26 const e = this.store.get(k);27 if (!e) return null;28 if (e.exp !== null && e.exp < Date.now()) {29 this.store.delete(k);30 return null;31 }32 return e;33 }34 async incrWithTtl(key: string, ttlSec: number) {35 const e = this.alive(key);36 const n = (e ? Number(e.v) : 0) + 1;37 this.store.set(key, { v: String(n), exp: e?.exp ?? Date.now() + ttlSec * 1000 });38 return n;39 }40 async decr(key: string) {41 const e = this.alive(key);42 const n = Math.max(0, (e ? Number(e.v) : 0) - 1);43 if (e) this.store.set(key, { v: String(n), exp: e.exp });44 return n;45 }46 async get(key: string) {47 return this.alive(key)?.v ?? null;48 }49 async set(key: string, value: string, ttlSec?: number) {50 this.store.set(key, { v: value, exp: ttlSec ? Date.now() + ttlSec * 1000 : null });51 }52 async del(key: string) {53 this.store.delete(key);54 }55 async slidingWindow(key: string, windowMs: number, limit: number) {56 const now = Date.now();57 const arr = (this.windows.get(key) ?? []).filter((t) => t > now - windowMs);58 arr.push(now);59 this.windows.set(key, arr);60 const allowed = arr.length <= limit;61 return { count: arr.length, allowed, retryAfterMs: allowed ? 0 : Math.max(0, arr[0]! + windowMs - now) };62 }63 async ping() {64 return true;65 }66}6768class RedisKV implements KV {69 kind = "redis" as const;70 constructor(readonly client: Redis) {}71 async incrWithTtl(key: string, ttlSec: number) {72 const res = await this.client.multi().incr(key).expire(key, ttlSec, "NX").exec();73 return Number(res?.[0]?.[1] ?? 0);74 }75 async decr(key: string) {76 const n = await this.client.decr(key);77 if (n < 0) await this.client.set(key, "0");78 return Math.max(0, n);79 }80 async get(key: string) {81 return this.client.get(key);82 }83 async set(key: string, value: string, ttlSec?: number) {84 if (ttlSec) await this.client.set(key, value, "EX", ttlSec);85 else await this.client.set(key, value);86 }87 async del(key: string) {88 await this.client.del(key);89 }90 async slidingWindow(key: string, windowMs: number, limit: number) {91 const now = Date.now();92 const member = `${now}-${Math.random().toString(36).slice(2, 8)}`;93 const res = await this.client94 .multi()95 .zremrangebyscore(key, 0, now - windowMs)96 .zadd(key, now, member)97 .zcard(key)98 .zrange(key, 0, 0, "WITHSCORES")99 .pexpire(key, windowMs)100 .exec();101 const count = Number(res?.[2]?.[1] ?? 0);102 const oldest = Number((res?.[3]?.[1] as string[] | undefined)?.[1] ?? now);103 const allowed = count <= limit;104 return { count, allowed, retryAfterMs: allowed ? 0 : Math.max(0, oldest + windowMs - now) };105 }106 async ping() {107 try {108 return (await this.client.ping()) === "PONG";109 } catch {110 return false;111 }112 }113}114115let _kv: KV | null = null;116117export function getKV(): KV {118 if (_kv) return _kv;119 if (config.redisUrl) {120 const client = new Redis(config.redisUrl, { maxRetriesPerRequest: 2, enableOfflineQueue: true, lazyConnect: false });121 client.on("error", (e) => console.error("[redis]", e.message));122 _kv = new RedisKV(client);123 } else {124 if (config.env === "production") console.warn("[redis] REDIS_URL not set — falling back to in-memory limits (single process only)");125 _kv = new MemoryKV();126 }127 return _kv;128}129130export async function closeKV(): Promise<void> {131 if (_kv && _kv.kind === "redis") await (_kv as RedisKV).client.quit().catch(() => {});132 _kv = null;133}134