import "server-only"; import { sql } from "drizzle-orm"; import { db } from "./db"; export type RateLimitOptions = { max: number; windowSec: number }; export type RateLimitResult = { limited: boolean; count: number }; /** * Sliding-window rate limit using a single atomic Postgres UPSERT. * Returns { limited: count > max, count }. The UPSERT resets the window * when the existing row is older than windowSec, otherwise increments * the count in place. */ export async function checkRateLimit( key: string, opts: RateLimitOptions, ): Promise { const { windowSec } = opts; const result = await db.execute(sql` INSERT INTO rate_limit_buckets (key, window_start, count, expires_at) VALUES (${key}, now(), 1, now() + (${windowSec} * interval '1 second')) ON CONFLICT (key) DO UPDATE SET count = CASE WHEN rate_limit_buckets.window_start < now() - (${windowSec} * interval '1 second') THEN 1 ELSE rate_limit_buckets.count + 1 END, window_start = CASE WHEN rate_limit_buckets.window_start < now() - (${windowSec} * interval '1 second') THEN now() ELSE rate_limit_buckets.window_start END, expires_at = now() + (${windowSec} * interval '1 second') RETURNING count; `); const count = Number((result.rows[0] as { count: number }).count); return { limited: count > opts.max, count }; } export async function rateLimitSweep(): Promise<{ removed: number }> { const r = await db.execute(sql`DELETE FROM rate_limit_buckets WHERE expires_at < now()`); return { removed: r.rowCount ?? 0 }; }