yiekheng 17f9ee179f feat(db,web): pg_trgm + indexes + Postgres-backed cache and rate-limit
- Add cacheEntries and rateLimitBuckets tables to schema
- Generate migration 0002_left_jimmy_woo.sql with pg_trgm extension and all indexes
- Implement cache.ts (get/set/delete/getOrSet/sweep) backed by Postgres
- Implement rate-limit.ts (sliding-window UPSERT) backed by Postgres
- Implement search.ts (trigramMatch / trigramRank helpers)
- Add vitest 2.1.9 + vitest.config.ts; 7 unit tests pass (4 cache + 3 rate-limit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:03:10 +08:00

44 lines
1.6 KiB
TypeScript

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<RateLimitResult> {
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 };
}