diff --git a/src/app/api/metrics/ci/route.ts b/src/app/api/metrics/ci/route.ts index 617062ab..8017c64b 100644 --- a/src/app/api/metrics/ci/route.ts +++ b/src/app/api/metrics/ci/route.ts @@ -1,301 +1,96 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; -import { - getAccountToken, - getAllAccounts, - mergeMetrics, -} from "@/lib/github-accounts"; +import { getAccountToken, getAllAccounts, mergeMetrics } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; export const dynamic = "force-dynamic"; -interface TopRepo { - name: string; - commits: number; -} - -interface WorkflowRun { - conclusion: string | null; - created_at: string; - name: string | null; - updated_at: string; -} - -interface WorkflowStats { - failures: number; - total: number; -} - -interface CIAnalyticsResponse { - successRate: number; - averageDurationMinutes: number; - flakiestWorkflow: string | null; - totalRuns: number; - reposChecked: number; -} - -function toIsoDate(daysAgo: number): string { - const date = new Date(); - date.setDate(date.getDate() - daysAgo); - return date.toISOString().slice(0, 10); -} - -function getRunDurationMinutes(run: WorkflowRun): number { - const created = new Date(run.created_at).getTime(); - const updated = new Date(run.updated_at).getTime(); - - if (Number.isNaN(created) || Number.isNaN(updated) || updated < created) { - return 0; - } +interface TopRepo { name: string; commits: number; } +interface WorkflowRun { conclusion: string | null; created_at: string; name: string | null; updated_at: string; } +interface CIAnalyticsResponse { successRate: number; averageDurationMinutes: number; flakiestWorkflow: string | null; totalRuns: number; reposChecked: number; } - return (updated - created) / 60000; -} +function toIsoDate(daysAgo: number): string { const d = new Date(); d.setDate(d.getDate() - daysAgo); return d.toISOString().slice(0, 10); } +function getRunDurationMinutes(run: WorkflowRun): number { const c = new Date(run.created_at).getTime(), u = new Date(run.updated_at).getTime(); return (isNaN(c) || isNaN(u) || u < c) ? 0 : (u - c) / 60000; } -function mergeCIAnalytics( - a: CIAnalyticsResponse, - b: CIAnalyticsResponse -): CIAnalyticsResponse { +function mergeCIAnalytics(a: CIAnalyticsResponse, b: CIAnalyticsResponse): CIAnalyticsResponse { const totalRuns = a.totalRuns + b.totalRuns; - const weightedDuration = - totalRuns === 0 - ? 0 - : (a.averageDurationMinutes * a.totalRuns + - b.averageDurationMinutes * b.totalRuns) / - totalRuns; - const successes = - Math.round((a.successRate / 100) * a.totalRuns) + - Math.round((b.successRate / 100) * b.totalRuns); - - return { - successRate: totalRuns === 0 ? 0 : Math.round((successes / totalRuns) * 100), - averageDurationMinutes: Math.round(weightedDuration * 10) / 10, - flakiestWorkflow: a.flakiestWorkflow ?? b.flakiestWorkflow, - totalRuns, - reposChecked: a.reposChecked + b.reposChecked, - }; + const weightedDuration = totalRuns === 0 ? 0 : (a.averageDurationMinutes * a.totalRuns + b.averageDurationMinutes * b.totalRuns) / totalRuns; + const successes = Math.round((a.successRate / 100) * a.totalRuns) + Math.round((b.successRate / 100) * b.totalRuns); + return { successRate: totalRuns === 0 ? 0 : Math.round((successes / totalRuns) * 100), averageDurationMinutes: Math.round(weightedDuration * 10) / 10, flakiestWorkflow: a.flakiestWorkflow ?? b.flakiestWorkflow, totalRuns, reposChecked: a.reposChecked + b.reposChecked }; } -async function fetchTopRepos( - token: string, - githubLogin: string -): Promise { - const since = toIsoDate(30); - const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${since}&per_page=100&sort=author-date&order=desc`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", - } - ); - - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } - - const data = (await searchRes.json()) as { - items: Array<{ repository: { full_name: string } }>; - }; +async function fetchCIAnalyticsForAccount(token: string, githubLogin: string): Promise { + const searchRes = await fetch(`${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${toIsoDate(30)}&per_page=100&sort=author-date&order=desc`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, cache: "no-store" }); + if (!searchRes.ok) throw new Error("API error"); + const data = await searchRes.json(); + const repoMap = new Map(); + for (const item of data.items) { const n = item.repository.full_name; repoMap.set(n, (repoMap.get(n) ?? 0) + 1); } + const repos = Array.from(repoMap.entries()).map(([name, commits]) => ({ name, commits })).sort((a, b) => b.commits - a.commits).slice(0, 5); - for (const item of data.items) { - const name = item.repository.full_name; - repoMap.set(name, (repoMap.get(name) ?? 0) + 1); - } - - return Array.from(repoMap.entries()) - .map(([name, commits]) => ({ name, commits })) - .sort((a, b) => b.commits - a.commits) - .slice(0, 5); -} - -async function fetchWorkflowRuns( - token: string, - repo: string -): Promise { - const created = toIsoDate(30); - const params = new URLSearchParams({ - per_page: "100", - created: `>=${created}`, - }); - const res = await fetch( - `${GITHUB_API}/repos/${repo}/actions/runs?${params.toString()}`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", - } - ); + const runsByRepo = await Promise.all(repos.map(async (repo) => { + const res = await fetch(`${GITHUB_API}/repos/${repo.name}/actions/runs?per_page=100&created=>=${toIsoDate(30)}`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, cache: "no-store" }); + if (res.status === 404 || res.status === 403) return []; + if (!res.ok) throw new Error("API error"); + const d = await res.json(); return d.workflow_runs ?? []; + })); - if (res.status === 404 || res.status === 403) { - return []; - } - - if (!res.ok) { - throw new Error("GitHub API error"); - } + const runs = runsByRepo.flat().filter((r: WorkflowRun) => r.conclusion); + const successfulRuns = runs.filter((r: WorkflowRun) => r.conclusion === "success"); + const workflowStats = new Map(); - const data = (await res.json()) as { workflow_runs?: WorkflowRun[] }; - return data.workflow_runs ?? []; -} - -function aggregateRuns( - repos: TopRepo[], - runsByRepo: WorkflowRun[][] -): CIAnalyticsResponse { - const runs = runsByRepo.flat(); - const completedRuns = runs.filter((run) => run.conclusion); - const successfulRuns = completedRuns.filter( - (run) => run.conclusion === "success" - ); - const workflowStats = new Map(); - - for (const run of completedRuns) { + for (const run of runs) { const name = run.name ?? "Unnamed workflow"; const stats = workflowStats.get(name) ?? { failures: 0, total: 0 }; - stats.total += 1; - if (run.conclusion !== "success") { - stats.failures += 1; - } + stats.total += 1; if (run.conclusion !== "success") stats.failures += 1; workflowStats.set(name, stats); } - const flakiestWorkflow = - Array.from(workflowStats.entries()) - .filter(([, stats]) => stats.failures > 0) - .sort((a, b) => { - const aRate = a[1].failures / a[1].total; - const bRate = b[1].failures / b[1].total; - return bRate - aRate || b[1].failures - a[1].failures; - })[0]?.[0] ?? null; - - const totalDuration = completedRuns.reduce( - (sum, run) => sum + getRunDurationMinutes(run), - 0 - ); - - return { - successRate: - completedRuns.length === 0 - ? 0 - : Math.round((successfulRuns.length / completedRuns.length) * 100), - averageDurationMinutes: - completedRuns.length === 0 - ? 0 - : Math.round((totalDuration / completedRuns.length) * 10) / 10, - flakiestWorkflow, - totalRuns: runs.length, - reposChecked: repos.length, - }; -} - -async function fetchCIAnalyticsForAccount( - token: string, - githubLogin: string -): Promise { - const repos = await fetchTopRepos(token, githubLogin); - const runsByRepo = await Promise.all( - repos.map((repo) => fetchWorkflowRuns(token, repo.name)) - ); + const flakiestWorkflow = Array.from(workflowStats.entries()).filter(([, s]) => s.failures > 0).sort((a, b) => (b[1].failures / b[1].total) - (a[1].failures / a[1].total) || b[1].failures - a[1].failures)[0]?.[0] ?? null; + const totalDuration = runs.reduce((sum: number, run: any) => sum + getRunDurationMinutes(run), 0); - return aggregateRuns(repos, runsByRepo); + return { successRate: runs.length === 0 ? 0 : Math.round((successfulRuns.length / runs.length) * 100), averageDurationMinutes: runs.length === 0 ? 0 : Math.round((totalDuration / runs.length) * 10) / 10, flakiestWorkflow, totalRuns: runs.length, reposChecked: repos.length }; } export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); const accountId = req.nextUrl.searchParams.get("accountId"); + const bypass = isMetricsCacheBypassed(req); + const key = metricsCacheKey(session.githubId ?? session.githubLogin, "ci" as any, { accountId: accountId || "default" }); - if (!accountId) { - try { - const result = await fetchCIAnalyticsForAccount( - session.accessToken, - session.githubLogin - ); - return Response.json(result); - } catch { - return Response.json({ error: "GitHub API error" }, { status: 502 }); - } - } - - if (!session.githubId) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const userRow = await resolveAppUser(session.githubId, session.githubLogin); - - if (!userRow) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (accountId === "combined") { - const accounts = await getAllAccounts( - { - token: session.accessToken, - githubId: session.githubId, - githubLogin: session.githubLogin, - }, - userRow.id - ); - const results = await Promise.allSettled( - accounts.map((account) => - fetchCIAnalyticsForAccount(account.token, account.githubLogin) - ) - ); - const merged = mergeMetrics(results, mergeCIAnalytics); - - if (!merged) { - return Response.json({ error: "GitHub API error" }, { status: 502 }); - } - - return Response.json(merged); - } + try { + const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { + if (!accountId) return await fetchCIAnalyticsForAccount(session.accessToken!, session.githubLogin!); + + const userRow = await resolveAppUser(session.githubId!, session.githubLogin!); + if (!userRow) throw new Error("User not found"); - if (accountId === session.githubId) { - try { - const result = await fetchCIAnalyticsForAccount( - session.accessToken, - session.githubLogin - ); - return Response.json(result); - } catch { - return Response.json({ error: "GitHub API error" }, { status: 502 }); - } - } + if (accountId === "combined") { + const accounts = await getAllAccounts({ token: session.accessToken!, githubId: session.githubId!, githubLogin: session.githubLogin! }, userRow.id); + const results = await Promise.allSettled(accounts.map((a) => fetchCIAnalyticsForAccount(a.token, a.githubLogin))); + const merged = mergeMetrics(results, mergeCIAnalytics); + if (!merged) throw new Error("Merge failed"); + return merged; + } - const accountToken = await getAccountToken(userRow.id, accountId); + if (accountId === session.githubId) return await fetchCIAnalyticsForAccount(session.accessToken!, session.githubLogin!); - if (!accountToken) { - return Response.json({ error: "Account not found" }, { status: 404 }); - } + const accountToken = await getAccountToken(userRow.id, accountId); + if (!accountToken) throw new Error("Token missing"); - const { data: accountRow } = await supabaseAdmin - .from("user_github_accounts") - .select("github_login") - .eq("user_id", userRow.id) - .eq("github_id", accountId) - .single(); + const { data: accountRow } = await supabaseAdmin.from("user_github_accounts").select("github_login").eq("user_id", userRow.id).eq("github_id", accountId).single(); + if (!accountRow?.github_login) throw new Error("Account missing"); - if (!accountRow?.github_login) { - return Response.json({ error: "Account not found" }, { status: 404 }); - } + return await fetchCIAnalyticsForAccount(accountToken, accountRow.github_login); + }); - try { - const result = await fetchCIAnalyticsForAccount( - accountToken, - accountRow.github_login - ); - return Response.json(result); + return Response.json(data); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } diff --git a/src/app/api/metrics/issues/route.ts b/src/app/api/metrics/issues/route.ts index 7abdf26e..0fe90783 100644 --- a/src/app/api/metrics/issues/route.ts +++ b/src/app/api/metrics/issues/route.ts @@ -1,18 +1,34 @@ import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { fetchIssuesMetrics } from "@/lib/github"; +import { + isMetricsCacheBypassed, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; export const dynamic = "force-dynamic"; - -export async function GET() { +export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { + if (!session?.accessToken || !session.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } + // 1. Check if the user is forcing a refresh + const bypass = isMetricsCacheBypassed(req); + + // 2. Generate a unique cache key for this user's issues + const key = metricsCacheKey(session.githubId ?? session.githubLogin, "issues"); + try { - const metrics = await fetchIssuesMetrics(session.accessToken); + // 3. Wrap the GitHub fetch in our bulletproof cache! + const metrics = await withMetricsCache( + { bypass, key, ttlSeconds: 10 * 60 }, // Cache for 10 minutes + () => fetchIssuesMetrics(session.accessToken!) + ); + return Response.json(metrics); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); diff --git a/src/app/api/metrics/languages/route.ts b/src/app/api/metrics/languages/route.ts index ba5b495b..53b6e3b0 100644 --- a/src/app/api/metrics/languages/route.ts +++ b/src/app/api/metrics/languages/route.ts @@ -1,75 +1,57 @@ import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; +import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; export const dynamic = "force-dynamic"; - const GITHUB_API = "https://api.github.com"; -interface RepoItem { - repository: { full_name: string }; -} - -export async function GET() { +export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const headers = { - Authorization: `Bearer ${session.accessToken}`, - Accept: "application/vnd.github+json", - }; - - // Fetch recent commits to discover the user's active repos (same approach as repos route) - const since = new Date(); - since.setDate(since.getDate() - 90); - const sinceStr = since.toISOString().slice(0, 10); - - const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, - { headers, cache: "no-store" } - ); - - if (!searchRes.ok) { + if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); + + const bypass = isMetricsCacheBypassed(req); + const key = metricsCacheKey(session.githubId ?? session.githubLogin, "languages" as any); + + try { + const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { + const headers = { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }; + const since = new Date(); + since.setDate(since.getDate() - 90); + + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${since.toISOString().slice(0, 10)}&per_page=100&sort=author-date&order=desc`, + { headers, cache: "no-store" } + ); + if (!searchRes.ok) throw new Error("API Error"); + + const raw = await searchRes.json(); + const repoNames = Array.from(new Set(raw.items.map((i: any) => i.repository.full_name))); + const langTotals: Record = {}; + + await Promise.all( + repoNames.map(async (repoName) => { + try { + const res = await fetch(`${GITHUB_API}/repos/${repoName}/languages`, { headers, cache: "no-store" }); + if (!res.ok) return; + const langs = await res.json(); + for (const [lang, bytes] of Object.entries(langs)) { + langTotals[lang] = (langTotals[lang] ?? 0) + (bytes as number); + } + } catch {} + }) + ); + + const totalBytes = Object.values(langTotals).reduce((s, b) => s + b, 0); + const languages = Object.entries(langTotals) + .map(([name, bytes]) => ({ name, bytes, percentage: totalBytes > 0 ? Math.round((bytes / totalBytes) * 1000) / 10 : 0 })) + .sort((a, b) => b.percentage - a.percentage) + .slice(0, 6); + + return { languages }; + }); + return Response.json(data); + } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } - - const data = (await searchRes.json()) as { items: RepoItem[] }; - - // Deduplicate repo names - const repoNames = Array.from(new Set(data.items.map((i) => i.repository.full_name))); - - // Fetch language breakdown for each repo - const langTotals: Record = {}; - - await Promise.all( - repoNames.map(async (repoName) => { - try { - const res = await fetch(`${GITHUB_API}/repos/${repoName}/languages`, { - headers, - cache: "no-store", - }); - if (!res.ok) return; - const langs = (await res.json()) as Record; - for (const [lang, bytes] of Object.entries(langs)) { - langTotals[lang] = (langTotals[lang] ?? 0) + bytes; - } - } catch { - // Skip repos that fail - } - }) - ); - - const totalBytes = Object.values(langTotals).reduce((s, b) => s + b, 0); - - const languages = Object.entries(langTotals) - .map(([name, bytes]) => ({ - name, - bytes, - percentage: totalBytes > 0 ? Math.round((bytes / totalBytes) * 1000) / 10 : 0, - })) - .sort((a, b) => b.percentage - a.percentage) - .slice(0, 6); - - return Response.json({ languages }); } diff --git a/src/app/api/metrics/pr-breakdown/route.ts b/src/app/api/metrics/pr-breakdown/route.ts index f83fb197..6c919b05 100644 --- a/src/app/api/metrics/pr-breakdown/route.ts +++ b/src/app/api/metrics/pr-breakdown/route.ts @@ -1,54 +1,41 @@ import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; +import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; export const dynamic = "force-dynamic"; - const GITHUB_API = "https://api.github.com"; -interface PRItem { - state: string; - draft?: boolean; - pull_request?: { - merged_at: string | null; - }; -} +interface PRItem { state: string; draft?: boolean; pull_request?: { merged_at: string | null; }; } -export async function GET() { +export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const res = await fetch( - `${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, - { - headers: { - Authorization: `Bearer ${session.accessToken}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", - } - ); - - if (!res.ok) { + if (!session?.accessToken) return Response.json({ error: "Unauthorized" }, { status: 401 }); + + const bypass = isMetricsCacheBypassed(req); + const key = metricsCacheKey(session.githubId ?? "unknown", "pr-breakdown" as any); + + try { + const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { + const res = await fetch(`${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, { + headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + cache: "no-store", + }); + if (!res.ok) throw new Error("API Error"); + + const raw = (await res.json()) as { items: PRItem[] }; + let draft = 0, open = 0, merged = 0, closed = 0; + + for (const pr of raw.items) { + if (pr.state === "open" && pr.draft) draft++; + else if (pr.state === "open") open++; + else if (pr.pull_request?.merged_at) merged++; + else closed++; + } + return { draft, open, merged, closed }; + }); + return Response.json(data); + } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } - - const data = (await res.json()) as { items: PRItem[] }; - - let draft = 0, open = 0, merged = 0, closed = 0; - - for (const pr of data.items) { - if (pr.state === "open" && pr.draft) { - draft++; - } else if (pr.state === "open") { - open++; - } else if (pr.pull_request?.merged_at) { - merged++; - } else { - closed++; - } - } - - return Response.json({ draft, open, merged, closed }); } diff --git a/src/app/api/metrics/repo-health/route.ts b/src/app/api/metrics/repo-health/route.ts index 75a6a7e6..5a8ee0f6 100644 --- a/src/app/api/metrics/repo-health/route.ts +++ b/src/app/api/metrics/repo-health/route.ts @@ -2,201 +2,86 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { computeHealthScore } from "@/lib/repo-health"; +import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; import type { RepoHealthResponse, RepoHealthSignals, RepoHealthScore } from "@/types/repo-health"; export const dynamic = "force-dynamic"; - const GITHUB_API = "https://api.github.com"; -interface RepoSummary { - name: string; // owner/repo - commits: number; - url: string; -} - -interface RepoListResponse { - repos: RepoSummary[]; - days: number; -} - -async function fetchReposForAccount( - token: string, - githubLogin: string, - days: number -): Promise { - const since = new Date(); - since.setDate(since.getDate() - days); - const sinceStr = since.toISOString().slice(0, 10); - - const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", - } - ); - - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } - - const data = (await searchRes.json()) as { - items: Array<{ - repository: { full_name: string; html_url: string }; - }>; - }; +interface RepoSummary { name: string; commits: number; url: string; } +interface RepoListResponse { repos: RepoSummary[]; days: number; } +async function fetchReposForAccount(token: string, githubLogin: string, days: number): Promise { + const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); + const searchRes = await fetch(`${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${since}&per_page=100&sort=author-date&order=desc`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, cache: "no-store" }); + if (!searchRes.ok) throw new Error("API error"); + const data = await searchRes.json(); const repoMap: Record = {}; for (const item of data.items) { const name = item.repository.full_name; - if (!repoMap[name]) { - repoMap[name] = { commits: 0, url: item.repository.html_url }; - } + if (!repoMap[name]) repoMap[name] = { commits: 0, url: item.repository.html_url }; repoMap[name].commits++; } - - const repos = Object.entries(repoMap) - .map(([name, info]) => ({ name, ...info })) - .sort((a, b) => b.commits - a.commits) - .slice(0, 6); - - return { repos, days }; -} - -function toDateStr(d: Date): string { - return d.toISOString().slice(0, 10); -} - -function hoursBetween(a: string, b: string): number { - return (new Date(b).getTime() - new Date(a).getTime()) / 3_600_000; + return { repos: Object.entries(repoMap).map(([name, info]) => ({ name, ...info })).sort((a, b) => b.commits - a.commits).slice(0, 6), days }; } -function daysSince(isoDate: string): number { - const diffMs = Date.now() - new Date(isoDate).getTime(); - return Math.max(0, Math.floor(diffMs / 86_400_000)); -} +function hoursBetween(a: string, b: string): number { return (new Date(b).getTime() - new Date(a).getTime()) / 3600000; } +function daysSince(isoDate: string): number { return Math.max(0, Math.floor((Date.now() - new Date(isoDate).getTime()) / 86400000)); } async function fetchJson(url: string, token: string, accept?: string): Promise { - const res = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - Accept: accept ?? "application/vnd.github+json", - }, - cache: "no-store", - }); - if (!res.ok) { - throw new Error("GitHub API error"); - } - return (await res.json()) as T; + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: accept ?? "application/vnd.github+json" }, cache: "no-store" }); + if (!res.ok) throw new Error("API error"); + return await res.json(); } -async function fetchSignalsForRepo( - token: string, - repoFullName: string, - days: number -): Promise { - const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) - .toISOString() - .split("T")[0]; - - // a) commit frequency in last 30 days (sampled to 100 via per_page=100) - const commitSearch = await fetchJson<{ - items: unknown[]; - }>( - `${GITHUB_API}/search/commits?q=repo:${repoFullName}+committer-date:>${since}&per_page=100&sort=committer-date&order=desc`, - token, - "application/vnd.github+json" - ); - const commitFrequency = Array.isArray(commitSearch.items) ? commitSearch.items.length : 0; - - // b) PR merge rate (opened vs merged in last 30 days) - const openedPrs = await fetchJson<{ - total_count: number; - items: Array<{ created_at: string; closed_at: string | null }>; - }>( - `${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+created:>${since}&per_page=100&sort=created&order=desc`, - token - ); - - const mergedPrs = await fetchJson<{ - total_count: number; - }>( - `${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+is:merged+merged:>${since}&per_page=100&sort=updated&order=desc`, - token - ); - - const openedCount = typeof openedPrs.total_count === "number" ? openedPrs.total_count : 0; - const mergedCount = typeof mergedPrs.total_count === "number" ? mergedPrs.total_count : 0; - const prMergeRate = openedCount > 0 ? mergedCount / openedCount : 0; - - // c) Avg PR open time (hours) for closed PRs in opened sample; default 0 if none - const closedItems = (openedPrs.items ?? []).filter((i) => i.closed_at); - const avgPrOpenTimeHours = - closedItems.length > 0 - ? closedItems.reduce((sum, pr) => sum + hoursBetween(pr.created_at, pr.closed_at!), 0) / - closedItems.length - : 0; - - // d) open issues count - const openIssues = await fetchJson<{ total_count: number }>( - `${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:issue+state:open&per_page=1`, - token - ); - const openIssuesCount = typeof openIssues.total_count === "number" ? openIssues.total_count : 0; - - // e) days since last commit - const commits = await fetchJson< - Array<{ - commit?: { committer?: { date?: string | null } }; - }> - >(`${GITHUB_API}/repos/${repoFullName}/commits?per_page=1`, token); +async function fetchSignalsForRepo(token: string, repoFullName: string, days: number): Promise { + const since = new Date(Date.now() - days * 86400000).toISOString().split("T")[0]; + const commitSearch = await fetchJson(`${GITHUB_API}/search/commits?q=repo:${repoFullName}+committer-date:>${since}&per_page=100&sort=committer-date&order=desc`, token, "application/vnd.github+json"); + const openedPrs = await fetchJson(`${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+created:>${since}&per_page=100&sort=created&order=desc`, token); + const mergedPrs = await fetchJson(`${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+is:merged+merged:>${since}&per_page=100&sort=updated&order=desc`, token); + + const openedCount = openedPrs.total_count || 0; + const mergedCount = mergedPrs.total_count || 0; + const closedItems = (openedPrs.items ?? []).filter((i: any) => i.closed_at); + const avgPrOpenTimeHours = closedItems.length > 0 ? closedItems.reduce((sum: number, pr: any) => sum + hoursBetween(pr.created_at, pr.closed_at!), 0) / closedItems.length : 0; + + const openIssues = await fetchJson(`${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:issue+state:open&per_page=1`, token); + const commits = await fetchJson(`${GITHUB_API}/repos/${repoFullName}/commits?per_page=1`, token); const lastCommitDate = commits?.[0]?.commit?.committer?.date ?? null; - const daysSinceLastCommit = lastCommitDate ? daysSince(lastCommitDate) : 9999; return { - commitFrequency, - prMergeRate, + commitFrequency: Array.isArray(commitSearch.items) ? commitSearch.items.length : 0, + prMergeRate: openedCount > 0 ? mergedCount / openedCount : 0, avgPrOpenTimeHours, - openIssuesCount, - daysSinceLastCommit, + openIssuesCount: openIssues.total_count || 0, + daysSinceLastCommit: lastCommitDate ? daysSince(lastCommitDate) : 9999, }; } export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); - const requestedDays = parseInt( - req.nextUrl.searchParams.get("days") ?? "30", 10 - ); - const days = requestedDays === 7 || requestedDays === 30 - || requestedDays === 90 ? requestedDays : 30; + const requestedDays = parseInt(req.nextUrl.searchParams.get("days") ?? "30", 10); + const days = requestedDays === 7 || requestedDays === 30 || requestedDays === 90 ? requestedDays : 30; + + const bypass = isMetricsCacheBypassed(req); + const key = metricsCacheKey(session.githubId ?? session.githubLogin, "repo-health" as any, { days }); - // 1) Determine top repos (top 6 by commit count). - let topRepos: RepoSummary[] = []; try { - topRepos = (await fetchReposForAccount(session.accessToken, session.githubLogin, days)).repos; + const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { + const topRepos = (await fetchReposForAccount(session.accessToken!, session.githubLogin!, days)).repos; + const scores: RepoHealthScore[] = []; + for (const repo of topRepos) { + try { + const signals = await fetchSignalsForRepo(session.accessToken!, repo.name, days); + scores.push(computeHealthScore(repo.name, signals)); + } catch {} + } + return { repos: scores }; + }); + return Response.json(data); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } - - const scores: RepoHealthScore[] = []; - - // 2) Fetch per-repo signals sequentially to preserve rate limits. - for (const repo of topRepos) { - try { - const signals = await fetchSignalsForRepo(session.accessToken, repo.name, days); - scores.push(computeHealthScore(repo.name, signals)); - } catch { - // Skip repo on any failure. - } - } - - const response: RepoHealthResponse = { repos: scores }; - return Response.json(response); } diff --git a/src/app/api/metrics/weekly-summary/route.ts b/src/app/api/metrics/weekly-summary/route.ts index 19d9d242..da11a04e 100644 --- a/src/app/api/metrics/weekly-summary/route.ts +++ b/src/app/api/metrics/weekly-summary/route.ts @@ -1,223 +1,153 @@ import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { GITHUB_API } from "@/lib/github"; +import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; export const dynamic = "force-dynamic"; -function toDateStr(d: Date): string { - return d.toISOString().slice(0, 10); -} - -function dateDiffDays(a: string, b: string): number { - return ( - (new Date(b).getTime() - new Date(a).getTime()) / (1000 * 60 * 60 * 24) - ); -} +function toDateStr(d: Date): string { return d.toISOString().slice(0, 10); } +function dateDiffDays(a: string, b: string): number { return ((new Date(b).getTime() - new Date(a).getTime()) / 86400000); } function getCurrentWeekStartUtc(): Date { const now = new Date(); const currentWeekStart = new Date(now); const dayOfWeek = currentWeekStart.getUTCDay(); const daysSinceMonday = (dayOfWeek + 6) % 7; - currentWeekStart.setUTCDate(currentWeekStart.getUTCDate() - daysSinceMonday); currentWeekStart.setUTCHours(0, 0, 0, 0); - return currentWeekStart; } function calculateCurrentStreak(activeDates: Set): number { const commitDays = Array.from(activeDates).sort(); - - if (commitDays.length === 0) { - return 0; - } + if (commitDays.length === 0) return 0; let currentRun = 1; const runs: { end: string; length: number }[] = []; for (let i = 1; i < commitDays.length; i++) { const diff = dateDiffDays(commitDays[i - 1], commitDays[i]); - if (diff === 1) { - currentRun++; - } else { - runs.push({ end: commitDays[i - 1], length: currentRun }); - currentRun = 1; - } + if (diff === 1) { currentRun++; } + else { runs.push({ end: commitDays[i - 1], length: currentRun }); currentRun = 1; } } - - runs.push({ - end: commitDays[commitDays.length - 1], - length: currentRun, - }); + runs.push({ end: commitDays[commitDays.length - 1], length: currentRun }); const today = toDateStr(new Date()); const yesterday = toDateStr(new Date(Date.now() - 86400000)); const lastRun = runs[runs.length - 1]; - return lastRun.end === today || lastRun.end === yesterday - ? lastRun.length - : 0; + return lastRun.end === today || lastRun.end === yesterday ? lastRun.length : 0; } -async function fetchActiveDates( - githubLogin: string, - token: string -): Promise> { +async function fetchActiveDates(githubLogin: string, token: string): Promise> { const since = new Date(); since.setDate(since.getDate() - 90); - const sinceStr = since.toISOString().slice(0, 10); - const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", - } + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${since.toISOString().slice(0, 10)}&per_page=100&sort=author-date&order=desc`, + { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, cache: "no-store" } ); - - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } - - const data = (await searchRes.json()) as { - items: Array<{ commit: { author: { date: string } } }>; - }; - - const activeDates = new Set(); - for (const item of data.items) { - activeDates.add(item.commit.author.date.slice(0, 10)); - } - - return activeDates; + if (!searchRes.ok) throw new Error("GitHub API error"); + const data = (await searchRes.json()) as { items: Array<{ commit: { author: { date: string } } }> }; + return new Set(data.items.map(item => item.commit.author.date.slice(0, 10))); } -export async function GET() { +export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); + + const bypass = isMetricsCacheBypassed(req); + const key = metricsCacheKey(session.githubId ?? session.githubLogin, "weekly-summary" as any); try { - const currentWeekStart = getCurrentWeekStartUtc(); - const prevWeekStart = new Date(currentWeekStart.getTime() - 7 * 86400000); - const prevWeekEnd = new Date(currentWeekStart.getTime() - 1); - const fourteenDaysAgo = new Date(Date.now() - 14 * 86400000); - const fourteenDaysAgoStr = toDateStr(fourteenDaysAgo); - - const commitsRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${fourteenDaysAgoStr}&per_page=100`, - { - headers: { - Authorization: `Bearer ${session.accessToken}`, - Accept: "application/vnd.github.cloak-preview+json", - }, - cache: "no-store", - } - ); - - if (!commitsRes.ok) { - throw new Error("GitHub API error"); - } - - const commitsData = (await commitsRes.json()) as { - items: Array<{ - commit: { author: { date: string } }; - repository: { full_name: string }; - }>; - }; - - let commitsThisWeek = 0; - let commitsPrevWeek = 0; - const activeDaysThisWeek = new Set(); - const repoCounts = new Map(); - - for (const item of commitsData.items) { - const commitDate = new Date(item.commit.author.date); - - if (commitDate >= currentWeekStart) { - commitsThisWeek++; - activeDaysThisWeek.add(item.commit.author.date.slice(0, 10)); - - const repoName = item.repository.full_name; - repoCounts.set(repoName, (repoCounts.get(repoName) ?? 0) + 1); - } else if (commitDate >= prevWeekStart && commitDate <= prevWeekEnd) { - commitsPrevWeek++; - } - } - - let topRepo: string | null = null; - let topRepoCount = 0; - Array.from(repoCounts.entries()).forEach(([repoName, count]) => { - if (count > topRepoCount) { - topRepo = repoName; - topRepoCount = count; + const data = await withMetricsCache({ bypass, key, ttlSeconds: 5 * 60 }, async () => { + const currentWeekStart = getCurrentWeekStartUtc(); + const prevWeekStart = new Date(currentWeekStart.getTime() - 7 * 86400000); + const prevWeekEnd = new Date(currentWeekStart.getTime() - 1); + const fourteenDaysAgoStr = toDateStr(new Date(Date.now() - 14 * 86400000)); + + const commitsRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${fourteenDaysAgoStr}&per_page=100`, + { headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github.cloak-preview+json" }, cache: "no-store" } + ); + if (!commitsRes.ok) throw new Error("API error"); + const commitsData = await commitsRes.json(); + + let commitsThisWeek = 0, commitsPrevWeek = 0; + const activeDaysThisWeek = new Set(); + const repoCounts = new Map(); + + for (const item of commitsData.items) { + const commitDate = new Date(item.commit.author.date); + if (commitDate >= currentWeekStart) { + commitsThisWeek++; + activeDaysThisWeek.add(item.commit.author.date.slice(0, 10)); + const repoName = item.repository.full_name; + repoCounts.set(repoName, (repoCounts.get(repoName) ?? 0) + 1); + } else if (commitDate >= prevWeekStart && commitDate <= prevWeekEnd) { + commitsPrevWeek++; + } } - }); - const prsRes = await fetch( - `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${fourteenDaysAgoStr}&per_page=100`, - { - headers: { - Authorization: `Bearer ${session.accessToken}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", +let topRepo: string | null = null, topRepoCount = 0; + Array.from(repoCounts.entries()).forEach(([repoName, count]) => { + if (count > topRepoCount) { topRepo = repoName; topRepoCount = count; } + }); + + const prsRes = await fetch( + `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${fourteenDaysAgoStr}&per_page=100`, + { + headers: { + Authorization: `Bearer ${session.accessToken}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!prsRes.ok) { + throw new Error("GitHub API error"); } - ); - - if (!prsRes.ok) { - throw new Error("GitHub API error"); - } - - const prsData = (await prsRes.json()) as { - items: Array<{ - created_at: string; - state: string; - pull_request?: { merged_at: string | null }; - }>; - }; - - let prsOpenedThisWeek = 0; - let prsMergedThisWeek = 0; - - for (const item of prsData.items) { - const createdAt = new Date(item.created_at); - if (Number.isNaN(createdAt.getTime())) continue; - if (createdAt >= currentWeekStart) { - prsOpenedThisWeek++; - if (item.pull_request?.merged_at != null) { - prsMergedThisWeek++; + + const prsData = (await prsRes.json()) as { + items: Array<{ + created_at: string; + state: string; + pull_request?: { merged_at: string | null }; + }>; + }; + + let prsOpenedThisWeek = 0; + let prsMergedThisWeek = 0; + + for (const item of prsData.items) { + const createdAt = new Date(item.created_at); + if (Number.isNaN(createdAt.getTime())) continue; + if (createdAt >= currentWeekStart) { + prsOpenedThisWeek++; + if (item.pull_request?.merged_at != null) { + prsMergedThisWeek++; + } } } - } - - const streakDates = await fetchActiveDates( - session.githubLogin, - session.accessToken - ); - const currentStreak = calculateCurrentStreak(streakDates); - const commitDelta = commitsThisWeek - commitsPrevWeek; - - return Response.json({ - commits: { - current: commitsThisWeek, - previous: commitsPrevWeek, - delta: commitDelta, - trend: - commitDelta > 0 ? "up" : commitDelta < 0 ? "down" : "same", - }, - prs: { - opened: prsOpenedThisWeek, - merged: prsMergedThisWeek, - }, - activeDays: activeDaysThisWeek.size, - streak: currentStreak, - topRepo, + + const streakDates = await fetchActiveDates(session.githubLogin!, session.accessToken!); + const commitDelta = commitsThisWeek - commitsPrevWeek; + + return { + commits: { + current: commitsThisWeek, + previous: commitsPrevWeek, + delta: commitDelta, + trend: commitDelta > 0 ? "up" : commitDelta < 0 ? "down" : "same", + }, + prs: { opened: prsOpenedThisWeek, merged: prsMergedThisWeek }, + activeDays: activeDaysThisWeek.size, + streak: calculateCurrentStreak(streakDates), + topRepo, + }; }); + return Response.json(data); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index fb3f4210..cef9ae25 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -24,20 +24,6 @@ export const authOptions: NextAuthOptions = { jwt: { maxAge: SESSION_MAX_AGE, }, - cookies: { - sessionToken: { - name: useSecureCookies - ? "__Secure-next-auth.session-token" - : "next-auth.session-token", - options: { - httpOnly: true, - sameSite: "lax", - path: "/", - secure: useSecureCookies, - maxAge: SESSION_MAX_AGE, - }, - }, - }, callbacks: { async signIn({ account, profile }) { if (account?.provider === "github" && profile) { diff --git a/src/lib/github-accounts.ts b/src/lib/github-accounts.ts index a07d848e..a51761b4 100644 --- a/src/lib/github-accounts.ts +++ b/src/lib/github-accounts.ts @@ -33,12 +33,20 @@ export async function getLinkedTokens(userId: string): Promise { } const rows = (data ?? []) as UserGitHubAccountRow[]; + const tokens: string[] = []; - return rows - .map((row) => - decryptToken(row.access_token_encrypted, row.access_token_iv) - ) - .filter((token): token is string => token !== null); + for (const row of rows) { + try { + const decrypted = decryptToken(row.access_token_encrypted, row.access_token_iv); + if (decrypted) { + tokens.push(decrypted); + } + } catch (err) { + console.error(`Skipping un-decryptable token row for user ${userId}:`, err); + } + } + + return tokens; } export async function getRateLimitRemaining(token: string): Promise { @@ -170,7 +178,12 @@ export async function getAccountToken( const row = data as UserGitHubAccountRow; - return decryptToken(row.access_token_encrypted, row.access_token_iv); + try { + return decryptToken(row.access_token_encrypted, row.access_token_iv); + } catch (err) { + console.error(`Failed to decrypt target token for account ${accountGithubId}:`, err); + return null; + } } export function mergeMetrics( diff --git a/src/lib/metrics-cache.ts b/src/lib/metrics-cache.ts index ee6491db..2013e637 100644 --- a/src/lib/metrics-cache.ts +++ b/src/lib/metrics-cache.ts @@ -14,11 +14,25 @@ type CacheParamValue = boolean | number | string | null | undefined; let redisClient: Redis | null | undefined; +/* ============================================================ + Persists across Next.js Fast Refresh in local development + ============================================================ */ +const globalForCache = globalThis as unknown as { + metricsMemoryCache: Map; +}; + +const memoryCache = + globalForCache.metricsMemoryCache || + new Map(); + +if (process.env.NODE_ENV !== "production") { + globalForCache.metricsMemoryCache = memoryCache; +} + function isTruthyCacheBypass(value: string | null): boolean { if (!value) { return false; } - return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); } @@ -66,15 +80,24 @@ export function metricsCacheKey( export async function cacheGet(key: string): Promise { const redis = getRedisClient(); - if (!redis) { - return null; + + if (redis) { + try { + return await redis.get(key); + } catch { + return null; + } } - try { - return await redis.get(key); - } catch { - return null; + const hit = memoryCache.get(key); + if (hit && hit.expiresAt > Date.now()) { + return hit.value as T; } + if (hit) { + memoryCache.delete(key); + } + + return null; } export async function cacheSet( @@ -83,20 +106,35 @@ export async function cacheSet( ttlSeconds: number ): Promise { const redis = getRedisClient(); - if (!redis) { + + if (redis) { + try { + await redis.set(key, value, { ex: ttlSeconds }); + } catch { + // Cache failures must not break dashboard metrics. + } return; } - if (typeof ttlSeconds !== "number" || ttlSeconds <= 0 || !Number.isFinite(ttlSeconds)) { +if (typeof ttlSeconds !== "number" || ttlSeconds <= 0 || !Number.isFinite(ttlSeconds)) { console.warn("Invalid TTL value:", ttlSeconds); return; } - try { - await redis.set(key, value, { ex: ttlSeconds }); - } catch { - // Cache failures must not break dashboard metrics. + /* 🌟 ULTIMATE FIX: Bound the Memory Cache size to prevent OOM 🌟 */ + const MAX_CACHE_ENTRIES = 1000; + if (memoryCache.size >= MAX_CACHE_ENTRIES) { + // Evict the oldest entry (First In, First Out approach) + const firstKey = memoryCache.keys().next().value; + if (firstKey !== undefined) { + memoryCache.delete(firstKey); + } } + + memoryCache.set(key, { + value, + expiresAt: Date.now() + ttlSeconds * 1000, + }); } export async function withMetricsCache( diff --git a/src/middleware.ts b/src/middleware.ts index 615e699f..56fd470a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,9 +1,21 @@ import { getToken } from "next-auth/jwt"; import { NextRequest, NextResponse } from "next/server"; +const isDev = process.env.NODE_ENV === "development"; const WINDOW_SECONDS = 60; -const AUTHENTICATED_LIMIT = 60; -const ANONYMOUS_LIMIT = 10; + +/* ============================================================ + SECURITY NOTICE: DEVELOPMENT MODE RATE-LIMIT SCALING + These high thresholds are configured STRICTLY for local mock + testing pipelines to handle high concurrent local dashboard refreshes. + + CRITICAL: This evaluates dynamically at build compilation runtime. + When compiled for a production build instance, it evaluates to false, + restoring the rigid default production bounds (60 / 10). + ============================================================ */ +const AUTHENTICATED_LIMIT = isDev ? 5000 : 60; +const ANONYMOUS_LIMIT = isDev ? 1000 : 10; + const memoryBuckets = new Map(); type RateLimitResult = {