From 057d185b7c5fb62458fd903eb31f50fbc0acc268 Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:25:42 +0300 Subject: [PATCH 1/4] perf(db): add index on github_users(final_score DESC) --- src/lib/db/db-store.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/db/db-store.ts b/src/lib/db/db-store.ts index ba7c12f..8e779e3 100644 --- a/src/lib/db/db-store.ts +++ b/src/lib/db/db-store.ts @@ -112,6 +112,9 @@ export class DatabaseStore { CREATE INDEX IF NOT EXISTS idx_github_users_country_score ON github_users(country, final_score DESC); + CREATE INDEX IF NOT EXISTS idx_github_users_final_score + ON github_users(final_score DESC); + CREATE INDEX IF NOT EXISTS idx_github_users_stale ON github_users(stale_after) WHERE country IS NOT NULL; From 49b7da15129c6a5f963c8bac1b45c76834d8d58d Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:11:18 +0300 Subject: [PATCH 2/4] perf(db): replace SELECT * with lean column projections and remove redundant sorting --- src/lib/db/db-store.ts | 83 +++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/src/lib/db/db-store.ts b/src/lib/db/db-store.ts index 8e779e3..7f54ae2 100644 --- a/src/lib/db/db-store.ts +++ b/src/lib/db/db-store.ts @@ -23,6 +23,19 @@ export type GitHubUserRow; + +export type TopStaleUserRow = Pick; + export type UpsertUserParams = { username: string; name: string | null; @@ -244,7 +257,12 @@ export class DatabaseStore { async getUser(username: string): Promise { const client = getPool(); const result = await client.query( - "SELECT * FROM github_users WHERE LOWER(username) = LOWER($1)", + `SELECT + username, name, avatar_url, location, country, + raw_data, scores, repo_score, pr_score, contribution_score, final_score, + fetched_at, stale_after, created_at, updated_at + FROM github_users + WHERE LOWER(username) = LOWER($1)`, [username], ); return result.rows[0] ?? null; @@ -261,29 +279,34 @@ export class DatabaseStore { // ── Leaderboard operations ────────────────────────────────────────── - async getLeaderboard(country: string, limit: number = 500): Promise { + async getLeaderboard(country: string, limit: number = 500): Promise { const client = getPool(); - const result = await client.query( - `SELECT * - FROM ( - SELECT DISTINCT ON (LOWER(username)) * - FROM github_users - WHERE country = $1 - ORDER BY LOWER(username), final_score DESC, updated_at DESC - ) deduped_users - ORDER BY final_score DESC - LIMIT $2`, - [country, limit], - ); + const isWorldwide = country.trim().toLowerCase() === "worldwide"; + const result = isWorldwide + ? await client.query( + `SELECT username, name, avatar_url, repo_score, pr_score, contribution_score, final_score + FROM github_users + ORDER BY final_score DESC + LIMIT $1`, + [limit], + ) + : await client.query( + `SELECT username, name, avatar_url, repo_score, pr_score, contribution_score, final_score + FROM github_users + WHERE country = $1 + ORDER BY final_score DESC + LIMIT $2`, + [country, limit], + ); return result.rows; } async getLeaderboardCount(country: string): Promise { const client = getPool(); - const result = await client.query( - "SELECT COUNT(DISTINCT LOWER(username)) FROM github_users WHERE country = $1", - [country], - ); + const isWorldwide = country.trim().toLowerCase() === "worldwide"; + const result = isWorldwide + ? await client.query("SELECT COUNT(*) FROM github_users") + : await client.query("SELECT COUNT(*) FROM github_users WHERE country = $1", [country]); return Number(result.rows[0].count); } @@ -291,16 +314,12 @@ export class DatabaseStore { * Returns stale users in a country, ordered by score descending. * These are users whose data needs to be refreshed from GitHub. */ - async getTopStaleUsers(country: string, limit: number = 500): Promise { + async getTopStaleUsers(country: string, limit: number = 500): Promise { const client = getPool(); const result = await client.query( - `SELECT * - FROM ( - SELECT DISTINCT ON (LOWER(username)) * - FROM github_users - WHERE country = $1 AND stale_after < NOW() - ORDER BY LOWER(username), final_score DESC, updated_at DESC - ) deduped_users + `SELECT username, stale_after, final_score + FROM github_users + WHERE country = $1 AND stale_after < NOW() ORDER BY final_score DESC LIMIT $2`, [country, limit], @@ -312,16 +331,12 @@ export class DatabaseStore { * Returns the top-scoring users in a country regardless of staleness. * Used to determine which users to check for refresh. */ - async getTopUsers(country: string, limit: number = 500): Promise { + async getTopUsers(country: string, limit: number = 500): Promise { const client = getPool(); const result = await client.query( - `SELECT * - FROM ( - SELECT DISTINCT ON (LOWER(username)) * - FROM github_users - WHERE country = $1 - ORDER BY LOWER(username), final_score DESC, updated_at DESC - ) deduped_users + `SELECT username, stale_after, final_score + FROM github_users + WHERE country = $1 ORDER BY final_score DESC LIMIT $2`, [country, limit], From 9e04dfbf2af0a862c51b4d5c940b8b4127cbe513 Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:03:15 +0300 Subject: [PATCH 3/4] perf: optimize serverless execution, edge caching, and locale hydration --- src/app/api/leaderboard/route.ts | 9 ++++++++- src/app/api/user/[username]/route.ts | 9 ++++++++- src/app/layout.tsx | 2 +- src/app/leaderboard/[country]/page.tsx | 7 ++++++- src/app/scoring-methodology/page.tsx | 5 ++++- src/app/user/[username]/page.tsx | 3 +++ src/lib/db/db-store.ts | 6 ++++-- src/lib/i18n/provider-hook.ts | 22 +++------------------- src/middleware.ts | 24 +++++++++++++++++++++--- 9 files changed, 58 insertions(+), 29 deletions(-) diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 4da370f..d316219 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -16,7 +16,14 @@ export async function GET(request: Request) { try { const result = await getLeaderboardResult(country); - return NextResponse.json({ success: true, ...result }); + return NextResponse.json( + { success: true, ...result }, + { + headers: { + "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", + }, + }, + ); } catch (err) { console.error("Leaderboard DB query failed:", err); diff --git a/src/app/api/user/[username]/route.ts b/src/app/api/user/[username]/route.ts index 4a04a84..0b0fabc 100644 --- a/src/app/api/user/[username]/route.ts +++ b/src/app/api/user/[username]/route.ts @@ -20,7 +20,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ user try { const { user, location } = await getUserProfile(trimmed, selectedLanguages); - return NextResponse.json({ success: true, user, location }); + return NextResponse.json( + { success: true, user, location }, + { + headers: { + "Cache-Control": "public, s-maxage=1800, stale-while-revalidate=86400", + }, + }, + ); } catch (error: unknown) { console.error("User profile fetch error:", error); return formatApiErrorResponse(error); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d5744d8..7adb0f4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -99,7 +99,7 @@ export const metadata: Metadata = { export default async function RootLayout({ children }: { children: ReactNode }) { const cookieStore = await cookies(); const headerStore = await headers(); - const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value; + const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value || headerStore.get("x-locale"); const initialLocale = isSupportedLocale(cookieLocale) ? cookieLocale : parseAcceptLanguage(headerStore.get("accept-language"), supportedLocales, DEFAULT_LOCALE); diff --git a/src/app/leaderboard/[country]/page.tsx b/src/app/leaderboard/[country]/page.tsx index c6a191b..b5cf525 100644 --- a/src/app/leaderboard/[country]/page.tsx +++ b/src/app/leaderboard/[country]/page.tsx @@ -91,8 +91,13 @@ export async function generateMetadata({ params }: Props): Promise { }; } +export const revalidate = 3600; // Cache on Vercel Edge CDN for 1 hour via ISR +export const dynamicParams = true; + export async function generateStaticParams() { - return countries.map((country) => ({ country: country.slug })); + // Pre-render the top 20 most visited countries at build time. + // Other countries are generated on-demand upon first visit and cached by ISR. + return countries.slice(0, 20).map((country) => ({ country: country.slug })); } export default async function CountryLeaderboardPage({ params }: Props) { diff --git a/src/app/scoring-methodology/page.tsx b/src/app/scoring-methodology/page.tsx index 591daf1..b17878b 100644 --- a/src/app/scoring-methodology/page.tsx +++ b/src/app/scoring-methodology/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from "react"; import type { Metadata } from "next"; import { JsonLd } from "@/components/seo/json-ld"; import { ScoringMethodologyPageClient } from "@/features/scoring"; @@ -68,7 +69,9 @@ export default function ScoringMethodologyPage() { return ( <> - + }> + + ); } diff --git a/src/app/user/[username]/page.tsx b/src/app/user/[username]/page.tsx index a371828..61731dd 100644 --- a/src/app/user/[username]/page.tsx +++ b/src/app/user/[username]/page.tsx @@ -21,6 +21,9 @@ type CountryInfo = { const countries = countriesData as CountryInfo[]; +export const revalidate = 1800; // Cache user profile on Edge CDN for 30 minutes via ISR +export const dynamicParams = true; + type Props = { params: Promise<{ username: string }>; searchParams?: Promise<{ country?: string }>; diff --git a/src/lib/db/db-store.ts b/src/lib/db/db-store.ts index 7f54ae2..a29aeb2 100644 --- a/src/lib/db/db-store.ts +++ b/src/lib/db/db-store.ts @@ -67,10 +67,12 @@ function getPoolConfig(): PoolConfig { ); } + const isServerless = Boolean(process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME); + return { connectionString, - max: 10, - idleTimeoutMillis: 30_000, + max: isServerless ? 2 : 10, + idleTimeoutMillis: isServerless ? 10_000 : 30_000, connectionTimeoutMillis: 5_000, }; } diff --git a/src/lib/i18n/provider-hook.ts b/src/lib/i18n/provider-hook.ts index 53dc6fe..0c1f793 100644 --- a/src/lib/i18n/provider-hook.ts +++ b/src/lib/i18n/provider-hook.ts @@ -3,14 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import arMessages from "@/locales/ar.json"; import enMessages from "@/locales/en.json"; -import { - DEFAULT_LOCALE, - LOCALE_COOKIE, - isSupportedLocale, - localeMeta, - supportedLocales, - type Locale, -} from "./core"; +import { DEFAULT_LOCALE, LOCALE_COOKIE, localeMeta, supportedLocales, type Locale } from "./core"; type Messages = Record; @@ -32,17 +25,8 @@ function persistLocale(locale: Locale) { } export function useI18nProvider(initialLocale: Locale = DEFAULT_LOCALE) { - const getInitialLocale = () => { - if (typeof window !== "undefined") { - const stored = window.localStorage.getItem(LOCALE_COOKIE); - if (isSupportedLocale(stored)) return stored; - } - return initialLocale; - }; - - const initialLoc = getInitialLocale(); - const [locale, setLocaleState] = useState(initialLoc); - const [messages, setMessages] = useState(() => messagesByLocale[initialLoc]); + const [locale, setLocaleState] = useState(initialLocale); + const [messages, setMessages] = useState(() => messagesByLocale[initialLocale]); const [ready, setReady] = useState(true); const changeLocale = useCallback((next: Locale) => { diff --git a/src/middleware.ts b/src/middleware.ts index d4cbd14..1d1664b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,7 +9,7 @@ import { } from "@/lib/i18n/core"; export function middleware(request: NextRequest) { - const response = NextResponse.next(); + const requestHeaders = new Headers(request.headers); const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value; if (!isSupportedLocale(cookieLocale)) { @@ -18,11 +18,29 @@ export function middleware(request: NextRequest) { supportedLocales, DEFAULT_LOCALE, ); + requestHeaders.set("x-locale", locale); - response.cookies.set(LOCALE_COOKIE, locale, { path: "/" }); + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); + + response.cookies.set(LOCALE_COOKIE, locale, { + path: "/", + maxAge: 60 * 60 * 24 * 365, + sameSite: "lax", + }); + + return response; } - return response; + requestHeaders.set("x-locale", cookieLocale); + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); } // Run only on routes that produce HTML or read the cookie. Skip Next.js From 60a8ad5d6234be1b34c227e72e95d71773099c0e Mon Sep 17 00:00:00 2001 From: Osama Mabkhot <99215291+O2sa@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:26:05 +0300 Subject: [PATCH 4/4] perf(worker): eliminate DDL locks, batch user existence check, and add concurrent user fetching --- .../services/calculate-leaderboard.ts | 60 ++++-- .../tests/calculate-leaderboard.test.ts | 203 ++++++++++++++++++ src/lib/db/db-store.ts | 24 ++- src/lib/db/tests/db-store.test.ts | 60 ++++++ 4 files changed, 330 insertions(+), 17 deletions(-) create mode 100644 src/features/leaderboard/tests/calculate-leaderboard.test.ts create mode 100644 src/lib/db/tests/db-store.test.ts diff --git a/src/features/leaderboard/services/calculate-leaderboard.ts b/src/features/leaderboard/services/calculate-leaderboard.ts index d1a2aa4..68b15dc 100644 --- a/src/features/leaderboard/services/calculate-leaderboard.ts +++ b/src/features/leaderboard/services/calculate-leaderboard.ts @@ -54,6 +54,29 @@ function getRefreshLimit(): number { return getEnvInt("LEADERBOARD_REFRESH_LIMIT", 500); } +function getConcurrency(): number { + return getEnvInt("LEADERBOARD_CONCURRENCY", 3); +} + +async function runConcurrent( + items: T[], + concurrency: number, + task: (item: T) => Promise, +): Promise { + if (items.length === 0) return; + const limit = Math.max(1, Math.min(concurrency, items.length)); + let currentIndex = 0; + + const workers = Array.from({ length: limit }, async () => { + while (currentIndex < items.length) { + const index = currentIndex++; + await task(items[index]); + } + }); + + await Promise.all(workers); +} + function getSourceUrl(country: string): string { const template = process.env.LEADERBOARD_SOURCE_URL_TEMPLATE?.trim(); if (!template) { @@ -92,6 +115,7 @@ export async function seedNewUsers( users: LeaderboardSourceEntry[], seedLimit: number, staleDays: number, + concurrency: number = getConcurrency(), ): Promise<{ newUsersCount: number; skippedExistingCount: number; @@ -104,15 +128,19 @@ export async function seedNewUsers( const fetchMetrics: { duration: number; errors: { part: string; reason: string }[] }[] = []; const usersToSeed = users.slice(0, seedLimit); + const existingSet = await db.getExistingUsernames(usersToSeed.map((u) => u.login)); + const usersToFetch: LeaderboardSourceEntry[] = []; for (const user of usersToSeed) { - try { - const exists = await db.userExists(user.login); - if (exists) { - skippedExistingCount += 1; - continue; - } + if (existingSet.has(user.login.toLowerCase())) { + skippedExistingCount += 1; + } else { + usersToFetch.push(user); + } + } + await runConcurrent(usersToFetch, concurrency, async (user) => { + try { const { data, metrics } = await getUserData(user.login, { cacheInRedis: false, withMetrics: true, @@ -130,7 +158,7 @@ export async function seedNewUsers( } catch (e) { errors.push({ username: user.login, reason: e instanceof Error ? e.message : String(e) }); } - } + }); return { newUsersCount, skippedExistingCount, errors, fetchMetrics }; } @@ -140,6 +168,7 @@ export async function refreshStaleUsers( country: string, refreshLimit: number, staleDays: number, + concurrency: number = getConcurrency(), ): Promise<{ refreshedCount: number; errors: { username: string; reason: string }[]; @@ -150,12 +179,10 @@ export async function refreshStaleUsers( const fetchMetrics: { duration: number; errors: { part: string; reason: string }[] }[] = []; const topUsers = await db.getTopUsers(country, refreshLimit); + const now = new Date(); + const staleUsers = topUsers.filter((row) => row.stale_after < now); - for (const row of topUsers) { - if (row.stale_after >= new Date()) { - continue; - } - + await runConcurrent(staleUsers, concurrency, async (row) => { try { const { data, metrics } = await getUserData(row.username, { cacheInRedis: false, @@ -174,7 +201,7 @@ export async function refreshStaleUsers( } catch (e) { errors.push({ username: row.username, reason: e instanceof Error ? e.message : String(e) }); } - } + }); return { refreshedCount, errors, fetchMetrics }; } @@ -242,23 +269,24 @@ export async function calculateLeaderboard( refreshLimit?: number; staleDays?: number; displayLimit?: number; + concurrency?: number; }, ): Promise { const db = getDatabaseStore(); - await db.initializeSchema(); const staleDays = overrides?.staleDays ?? getStaleDays(); const seedLimit = overrides?.seedLimit ?? getSeedLimit(); const refreshLimit = overrides?.refreshLimit ?? getRefreshLimit(); + const concurrency = overrides?.concurrency ?? getConcurrency(); // 1. Fetch source users const sourceData = await fetchCommittersFromTop(country); // 2a. Seed new users - const seedResult = await seedNewUsers(db, sourceData.users, seedLimit, staleDays); + const seedResult = await seedNewUsers(db, sourceData.users, seedLimit, staleDays, concurrency); // 2b. Refresh stale users from DB top N - const refreshResult = await refreshStaleUsers(db, country, refreshLimit, staleDays); + const refreshResult = await refreshStaleUsers(db, country, refreshLimit, staleDays, concurrency); // 3. Build leaderboard result const allErrors = [ diff --git a/src/features/leaderboard/tests/calculate-leaderboard.test.ts b/src/features/leaderboard/tests/calculate-leaderboard.test.ts new file mode 100644 index 0000000..dbdd17a --- /dev/null +++ b/src/features/leaderboard/tests/calculate-leaderboard.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getUserData: vi.fn(), + calculateUserScore: vi.fn(), + persistUserScores: vi.fn().mockResolvedValue(undefined), + initializeSchema: vi.fn().mockResolvedValue(undefined), + getExistingUsernames: vi.fn().mockResolvedValue(new Set()), + getTopUsers: vi.fn().mockResolvedValue([]), + getLeaderboard: vi.fn().mockResolvedValue([]), + getLeaderboardCount: vi.fn().mockResolvedValue(0), +})); + +vi.mock("@/lib/github", () => ({ + getUserData: mocks.getUserData, +})); + +vi.mock("@/features/scoring", () => ({ + calculateUserScore: mocks.calculateUserScore, +})); + +vi.mock("@/features/developer/services", () => ({ + persistUserScores: mocks.persistUserScores, +})); + +vi.mock("@/lib/db", () => ({ + getDatabaseStore: () => ({ + initializeSchema: mocks.initializeSchema, + getExistingUsernames: mocks.getExistingUsernames, + getTopUsers: mocks.getTopUsers, + getLeaderboard: mocks.getLeaderboard, + getLeaderboardCount: mocks.getLeaderboardCount, + }), +})); + +vi.mock("@/lib/cache", () => ({ + getCacheConfigFromEnv: () => ({ enabled: false, namespace: "test" }), + createCacheStore: () => ({ enabled: false, del: vi.fn() }), +})); + +import type { DatabaseStore } from "@/lib/db"; +import { + seedNewUsers, + refreshStaleUsers, + calculateLeaderboard, +} from "../services/calculate-leaderboard"; + +describe("calculate-leaderboard service optimizations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("seedNewUsers", () => { + test("batches username existence checks and skips existing users", async () => { + const mockDb = { + getExistingUsernames: vi.fn().mockResolvedValue(new Set(["alice"])), + } as unknown as DatabaseStore; + + mocks.getUserData.mockResolvedValue({ + data: { login: "bob" }, + metrics: { duration: 12, errors: [] }, + }); + mocks.calculateUserScore.mockReturnValue({ finalScore: 85 }); + + const users = [ + { + rank: 1, + name: "Alice", + login: "alice", + avatarUrl: "https://example.com/1.png", + contributions: 100, + }, + { + rank: 2, + name: "Bob", + login: "bob", + avatarUrl: "https://example.com/2.png", + contributions: 50, + }, + ]; + + const result = await seedNewUsers(mockDb, users, 10, 30, 2); + + // getExistingUsernames was called once with both logins + expect(mockDb.getExistingUsernames).toHaveBeenCalledTimes(1); + expect(mockDb.getExistingUsernames).toHaveBeenCalledWith(["alice", "bob"]); + + // Alice was skipped, Bob was fetched + expect(result.skippedExistingCount).toBe(1); + expect(result.newUsersCount).toBe(1); + expect(mocks.getUserData).toHaveBeenCalledTimes(1); + expect(mocks.getUserData).toHaveBeenCalledWith("bob", { + cacheInRedis: false, + withMetrics: true, + }); + expect(mocks.persistUserScores).toHaveBeenCalledTimes(1); + }); + + test("handles errors for individual users without failing the batch", async () => { + const mockDb = { + getExistingUsernames: vi.fn().mockResolvedValue(new Set()), + } as unknown as DatabaseStore; + + mocks.getUserData + .mockRejectedValueOnce(new Error("Rate limit exceeded")) + .mockResolvedValueOnce({ + data: { login: "user2" }, + metrics: { duration: 10, errors: [] }, + }); + mocks.calculateUserScore.mockReturnValue({ finalScore: 90 }); + + const users = [ + { + rank: 1, + name: "User 1", + login: "user1", + avatarUrl: "https://example.com/1.png", + contributions: 10, + }, + { + rank: 2, + name: "User 2", + login: "user2", + avatarUrl: "https://example.com/2.png", + contributions: 20, + }, + ]; + + const result = await seedNewUsers(mockDb, users, 10, 30, 2); + + expect(result.newUsersCount).toBe(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toEqual({ + username: "user1", + reason: "Rate limit exceeded", + }); + }); + }); + + describe("refreshStaleUsers", () => { + test("only refreshes users whose stale_after is in the past", async () => { + const mockDb = { + getTopUsers: vi.fn().mockResolvedValue([ + { username: "staleUser", stale_after: new Date(Date.now() - 10000), final_score: 80 }, + { username: "freshUser", stale_after: new Date(Date.now() + 100000), final_score: 90 }, + ]), + } as unknown as DatabaseStore; + + mocks.getUserData.mockResolvedValue({ + data: { login: "staleUser" }, + metrics: { duration: 15, errors: [] }, + }); + mocks.calculateUserScore.mockReturnValue({ finalScore: 85 }); + + const result = await refreshStaleUsers(mockDb, "sweden", 10, 30, 2); + + expect(result.refreshedCount).toBe(1); + expect(mocks.getUserData).toHaveBeenCalledTimes(1); + expect(mocks.getUserData).toHaveBeenCalledWith("staleUser", { + cacheInRedis: false, + withMetrics: true, + }); + expect(mocks.persistUserScores).toHaveBeenCalledTimes(1); + }); + }); + + describe("calculateLeaderboard", () => { + test("does NOT call db.initializeSchema during execution", async () => { + process.env.LEADERBOARD_SOURCE_URL_TEMPLATE = "https://example.com/{country}.yml"; + + const sampleYaml = ` +title: Sweden +total_user_count: 1 +users: + - rank: 1 + name: Tester + login: tester + avatarUrl: https://example.com/tester.png + contributions: 10 +`; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(sampleYaml), + }), + ); + + mocks.getExistingUsernames.mockResolvedValue(new Set(["tester"])); + mocks.getTopUsers.mockResolvedValue([]); + mocks.getLeaderboard.mockResolvedValue([]); + mocks.getLeaderboardCount.mockResolvedValue(1); + + await calculateLeaderboard("sweden"); + + // Verify lock-eliminating fix: initializeSchema must NOT be called + expect(mocks.initializeSchema).not.toHaveBeenCalled(); + expect(mocks.getExistingUsernames).toHaveBeenCalledWith(["tester"]); + + vi.unstubAllGlobals(); + }); + }); +}); diff --git a/src/lib/db/db-store.ts b/src/lib/db/db-store.ts index a29aeb2..29b688b 100644 --- a/src/lib/db/db-store.ts +++ b/src/lib/db/db-store.ts @@ -73,7 +73,7 @@ function getPoolConfig(): PoolConfig { connectionString, max: isServerless ? 2 : 10, idleTimeoutMillis: isServerless ? 10_000 : 30_000, - connectionTimeoutMillis: 5_000, + connectionTimeoutMillis: 10_000, }; } @@ -279,6 +279,28 @@ export class DatabaseStore { return (result.rowCount ?? 0) > 0; } + async getExistingUsernames(usernames: string[]): Promise> { + if (!usernames.length) { + return new Set(); + } + + const lowerUsernames = Array.from( + new Set(usernames.map((u) => u.trim().toLowerCase()).filter(Boolean)), + ); + + if (lowerUsernames.length === 0) { + return new Set(); + } + + const client = getPool(); + const result = await client.query<{ username: string }>( + "SELECT LOWER(username) AS username FROM github_users WHERE LOWER(username) = ANY($1::text[])", + [lowerUsernames], + ); + + return new Set(result.rows.map((r) => r.username.toLowerCase())); + } + // ── Leaderboard operations ────────────────────────────────────────── async getLeaderboard(country: string, limit: number = 500): Promise { diff --git a/src/lib/db/tests/db-store.test.ts b/src/lib/db/tests/db-store.test.ts new file mode 100644 index 0000000..e16a452 --- /dev/null +++ b/src/lib/db/tests/db-store.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mockQuery = vi.fn(); + +vi.mock("pg", () => { + class MockPool { + query = mockQuery; + on = vi.fn(); + } + return { + Pool: MockPool, + }; +}); + +// Set dummy DATABASE_URL so getPoolConfig does not throw +process.env.DATABASE_URL = "postgresql://dummy:dummy@localhost:5432/dummydb"; + +import { DatabaseStore } from "../db-store"; + +describe("DatabaseStore.getExistingUsernames", () => { + let store: DatabaseStore; + + beforeEach(() => { + vi.clearAllMocks(); + store = new DatabaseStore(); + }); + + test("returns empty Set immediately when input array is empty without querying database", async () => { + const result = await store.getExistingUsernames([]); + expect(result.size).toBe(0); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + test("returns empty Set when input contains only empty or whitespace strings", async () => { + const result = await store.getExistingUsernames(["", " "]); + expect(result.size).toBe(0); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + test("normalizes, deduplicates, and queries database with ANY($1::text[])", async () => { + mockQuery.mockResolvedValueOnce({ + rows: [{ username: "alice" }, { username: "bob" }], + rowCount: 2, + }); + + const result = await store.getExistingUsernames([" Alice ", "ALICE", "bob", "Charlie"]); + + expect(mockQuery).toHaveBeenCalledTimes(1); + const [query, params] = mockQuery.mock.calls[0]; + expect(query).toContain( + "SELECT LOWER(username) AS username FROM github_users WHERE LOWER(username) = ANY($1::text[])", + ); + expect(params).toEqual([["alice", "bob", "charlie"]]); + + expect(result).toBeInstanceOf(Set); + expect(result.has("alice")).toBe(true); + expect(result.has("bob")).toBe(true); + expect(result.has("charlie")).toBe(false); + }); +});