- 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>
44 lines
1.6 KiB
TypeScript
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 };
|
|
}
|