import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg"; import crypto from "crypto"; import { config } from "./config"; import type { RaceRow } from "./mappers/race"; const poolConfig: PoolConfig = { host: config.db.host, port: config.db.port, database: config.db.database, user: config.db.user, password: config.db.password, max: 10, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 5_000, }; function mockRowFromInsert(sql: string, params: unknown[]): RaceRow { const match = sql.match(/INSERT INTO races\s*\(([^)]+)\)\s*VALUES/i); const now = new Date(); if (!match) { return { id: String(params[0] ?? ""), slug: String(params[0] ?? ""), owner_user_id: null, race_date: "", title: "", distance_km: "0", status: null, official_url: null, cover_image_url: null, start_time: null, cluster_schedule: null, bib_pickup: null, bib_number: null, finish_time: null, finish_place: null, notes: null, created_at: now, updated_at: null, }; } const cols = match[1].split(",").map((c) => c.trim()); const row: Record = {}; cols.forEach((col, i) => { row[col] = params[i]; }); return { id: String(row.id ?? crypto.randomUUID()), slug: String(row.slug ?? row.id ?? ""), owner_user_id: row.owner_user_id != null ? String(row.owner_user_id) : null, race_date: String(row.race_date ?? ""), title: String(row.title ?? ""), distance_km: String(row.distance_km ?? "0"), status: row.status != null ? String(row.status) : null, official_url: row.official_url != null ? String(row.official_url) : null, cover_image_url: row.cover_image_url != null ? String(row.cover_image_url) : null, start_time: row.start_time != null ? String(row.start_time) : null, cluster_schedule: row.cluster_schedule != null ? String(row.cluster_schedule) : null, bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null, bib_number: row.bib_number != null ? String(row.bib_number) : null, finish_time: row.finish_time != null ? String(row.finish_time) : null, finish_place: row.finish_place != null ? String(row.finish_place) : null, notes: row.notes != null ? String(row.notes) : null, created_at: now, updated_at: null, }; } function createMockPool(): Pool { const emptyResult = (): QueryResult => ({ rows: [], rowCount: 0, command: "", oid: 0, fields: [], }) as QueryResult; const store = new Map(); const users = new Map(); const sessions = new Map(); const verificationTokens = new Map(); const resetTokens = new Map(); const appSettings = new Map(); const result = (rows: T[], command = "SELECT"): QueryResult => ({ rows, rowCount: rows.length, command, oid: 0, fields: [] }) as QueryResult; const mockQuery = async ( text: string, params?: unknown[], ): Promise> => { const sql = text.replace(/\s+/g, " ").trim(); const p = params ?? []; if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK" || sql.includes("pg_advisory_xact_lock")) { return result([]); } if (sql.includes("SELECT COUNT(*)::text AS count FROM users")) { return result([{ count: String(users.size) } as unknown as T]); } if (sql.includes("SELECT COUNT(*)::text AS count FROM sessions")) { const tokenHash = p[0] != null ? String(p[0]) : null; const count = Array.from(sessions.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length; return result([{ count: String(count) } as unknown as T]); } if (sql.includes("SELECT COUNT(*)::text AS count FROM email_verification_tokens")) { const tokenHash = p[0] != null ? String(p[0]) : null; const count = Array.from(verificationTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length; return result([{ count: String(count) } as unknown as T]); } if (sql.includes("SELECT COUNT(*)::text AS count FROM password_reset_tokens")) { const tokenHash = p[0] != null ? String(p[0]) : null; const count = Array.from(resetTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length; return result([{ count: String(count) } as unknown as T]); } if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) { const email = String(p[0] ?? ""); const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email); return user ? result([user as T]) : emptyResult(); } if (sql.includes("SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email))")) { const email = String(p[0] ?? ""); const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email); return user ? result([user as T]) : emptyResult(); } if (sql.includes("INSERT INTO users")) { const email = String(p[0] ?? "").trim().toLowerCase(); const existing = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email); if (existing) { const err = new Error("duplicate key") as Error & { code?: string }; err.code = "23505"; throw err; } const id = crypto.randomUUID(); const row = { id, email: String(p[0] ?? ""), password_hash: String(p[1] ?? ""), email_verified_at: null, created_at: new Date(), updated_at: null, }; users.set(id, row); return result([row as unknown as T], "INSERT"); } if (sql.includes("INSERT INTO email_verification_tokens")) { const id = crypto.randomUUID(); const row = { id, user_id: String(p[0] ?? ""), token_hash: String(p[1] ?? ""), expires_at: p[2] ?? new Date(), used_at: null, created_at: new Date(), }; verificationTokens.set(id, row); return result([row as unknown as T], "INSERT"); } if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE id")) { const id = String(p[0] ?? ""); const row = verificationTokens.get(id); if (!row || row.used_at != null) { return result([], "UPDATE"); } row.used_at = new Date(); return result([{ id: row.id } as unknown as T], "UPDATE"); } if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) { const userId = String(p[0] ?? ""); const exceptId = p[1] != null ? String(p[1]) : null; for (const row of verificationTokens.values()) { if (row.user_id === userId && row.id !== exceptId && row.used_at == null) { row.used_at = new Date(); } } return result([], "UPDATE"); } if (sql.includes("FROM email_verification_tokens") && sql.includes("token_hash =")) { const tokenHash = String(p[0] ?? ""); const now = Date.now(); return result( Array.from(verificationTokens.values()).filter( (row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now, ) as T[], ); } if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) { const now = Date.now(); return result( Array.from(verificationTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[], ); } if (sql.includes("INSERT INTO password_reset_tokens")) { const id = crypto.randomUUID(); const row = { id, user_id: String(p[0] ?? ""), token_hash: String(p[1] ?? ""), expires_at: p[2] ?? new Date(), used_at: null, created_at: new Date(), }; resetTokens.set(id, row); return result([row as unknown as T], "INSERT"); } if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE id")) { const id = String(p[0] ?? ""); const row = resetTokens.get(id); if (!row || row.used_at != null) { return result([], "UPDATE"); } row.used_at = new Date(); return result([{ id: row.id } as unknown as T], "UPDATE"); } if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) { const userId = String(p[0] ?? ""); const exceptId = p[1] != null ? String(p[1]) : null; for (const row of resetTokens.values()) { if (row.user_id === userId && row.id !== exceptId && row.used_at == null) { row.used_at = new Date(); } } return result([], "UPDATE"); } if (sql.includes("FROM password_reset_tokens") && sql.includes("token_hash =")) { const tokenHash = String(p[0] ?? ""); const now = Date.now(); return result( Array.from(resetTokens.values()).filter( (row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now, ) as T[], ); } if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) { const now = Date.now(); return result( Array.from(resetTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[], ); } if (sql.includes("UPDATE users SET email_verified_at")) { const user = users.get(String(p[0] ?? "")); if (user) { user.email_verified_at = user.email_verified_at ?? new Date(); user.updated_at = new Date(); } return result([], "UPDATE"); } if (sql.includes("UPDATE users SET password_hash")) { const user = users.get(String(p[0] ?? "")); if (user) { user.password_hash = String(p[1] ?? ""); user.updated_at = new Date(); } return result([], "UPDATE"); } if (sql.includes("INSERT INTO sessions")) { const id = crypto.randomUUID(); const user = users.get(String(p[0] ?? "")); const row = { id, user_id: String(p[0] ?? ""), token_hash: String(p[1] ?? ""), csrf_token_hash: String(p[2] ?? ""), expires_at: p[3] ?? new Date(), email: user?.email ?? "", email_verified_at: user?.email_verified_at ?? null, revoked_at: null, created_at: new Date(), last_seen_at: new Date(), }; sessions.set(id, row); return result([row as unknown as T], "INSERT"); } if (sql.includes("FROM sessions s JOIN users u")) { const tokenHash = String(p[0] ?? ""); const now = Date.now(); const row = Array.from(sessions.values()).find( (item) => item.token_hash === tokenHash && !item.revoked_at && new Date(item.expires_at).getTime() > now, ); if (!row) { return emptyResult(); } const user = users.get(row.user_id); return result([{ ...row, email: user?.email ?? "", email_verified_at: user?.email_verified_at ?? null } as unknown as T]); } if (sql.includes("UPDATE sessions SET csrf_token_hash")) { const tokenHash = String(p[0] ?? ""); const row = Array.from(sessions.values()).find((item) => item.token_hash === tokenHash && !item.revoked_at); if (row) { row.csrf_token_hash = String(p[1] ?? ""); row.last_seen_at = new Date(); } return result(row ? ([{} as unknown as T]) : [], "UPDATE"); } if (sql.includes("UPDATE sessions SET last_seen_at")) { return result([], "UPDATE"); } if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE token_hash")) { const tokenHash = String(p[0] ?? ""); for (const row of sessions.values()) { if (row.token_hash === tokenHash) { row.revoked_at = new Date(); } } return result([], "UPDATE"); } if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE user_id")) { const userId = String(p[0] ?? ""); for (const row of sessions.values()) { if (row.user_id === userId && !row.revoked_at) { row.revoked_at = new Date(); } } return result([], "UPDATE"); } if (sql.includes("DELETE FROM sessions WHERE expires_at <= NOW()")) { const now = Date.now(); let deleted = 0; for (const [id, row] of sessions.entries()) { const revokedAt = row.revoked_at ? new Date(row.revoked_at).getTime() : null; const staleRevoked = revokedAt != null && revokedAt < now - 30 * 24 * 60 * 60 * 1000; if (new Date(row.expires_at).getTime() <= now || staleRevoked) { sessions.delete(id); deleted += 1; } } return result(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE"); } if (sql.includes("DELETE FROM email_verification_tokens WHERE expires_at <= NOW()")) { const now = Date.now(); let deleted = 0; for (const [id, row] of verificationTokens.entries()) { if (new Date(row.expires_at).getTime() <= now || row.used_at != null) { verificationTokens.delete(id); deleted += 1; } } return result(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE"); } if (sql.includes("DELETE FROM password_reset_tokens WHERE expires_at <= NOW()")) { const now = Date.now(); let deleted = 0; for (const [id, row] of resetTokens.entries()) { if (new Date(row.expires_at).getTime() <= now || row.used_at != null) { resetTokens.delete(id); deleted += 1; } } return result(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE"); } if (sql.includes("SELECT value FROM app_settings")) { const value = appSettings.get("orphan_races_claimed_by_user_id"); return value ? result([{ value } as unknown as T]) : emptyResult(); } if (sql.includes("INSERT INTO app_settings")) { appSettings.set("orphan_races_claimed_by_user_id", String(p[0] ?? "")); return result([], "INSERT"); } if (sql.includes("UPDATE races SET owner_user_id")) { const userId = String(p[0] ?? ""); for (const race of store.values()) { if (!race.owner_user_id) { race.owner_user_id = userId; race.updated_at = new Date(); } } return result([], "UPDATE"); } if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) { const row = mockRowFromInsert(text, p); const conflict = Array.from(store.values()).find( (item) => item.owner_user_id && item.owner_user_id === row.owner_user_id && item.slug === row.slug, ); if (conflict) { const err = new Error("duplicate key") as Error & { code?: string }; err.code = "23505"; throw err; } store.set(row.id, row); return { rows: [row as unknown as T], rowCount: 1, command: "INSERT", oid: 0, fields: [], } as QueryResult; } if (sql.includes("DELETE FROM races")) { const id = String(p[0] ?? ""); const ownerId = p[1] != null ? String(p[1]) : null; const existing = store.get(id); const existed = Boolean(existing && (!ownerId || existing.owner_user_id === ownerId)); if (existed) { store.delete(id); } return { rows: [], rowCount: existed ? 1 : 0, command: "DELETE", oid: 0, fields: [], } as QueryResult; } if (sql.includes("UPDATE races") && sql.includes("RETURNING")) { const id = String(p[p.length - 2] ?? p[p.length - 1] ?? ""); const ownerId = p[p.length - 1] != null ? String(p[p.length - 1]) : null; const existing = store.get(id); if (!existing || (ownerId && existing.owner_user_id !== ownerId)) { return emptyResult(); } const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/); const updated = { ...existing, updated_at: new Date() }; const setColumns = setMatch?.[1] .split(",") .map((part) => part.trim()) .filter((part) => !part.startsWith("updated_at")) .map((part) => part.split("=")[0]?.trim()) .filter((col): col is string => Boolean(col)) ?? []; setColumns.forEach((col, index) => { (updated as unknown as Record)[col] = p[index] ?? null; }); store.set(id, updated); return { rows: [updated as unknown as T], rowCount: 1, command: "UPDATE", oid: 0, fields: [], } as QueryResult; } if (sql.includes("SELECT * FROM races WHERE id =")) { const id = String(p[0] ?? ""); const ownerId = p[1] != null ? String(p[1]) : null; const row = store.get(id); return row && (!ownerId || row.owner_user_id === ownerId) ? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult : emptyResult(); } if (sql.includes("SELECT * FROM races")) { const ownerParam = p.find((value) => typeof value === "string" && /^[0-9a-f-]{36}$/i.test(value)); const rows = ownerParam ? Array.from(store.values()).filter((row) => row.owner_user_id === ownerParam) : Array.from(store.values()); return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult; } return emptyResult(); }; const mockPool = { query: mockQuery, connect: async () => { return { query: mockQuery, release() {}, }; }, end: async () => {}, on() { return mockPool; }, }; return mockPool as unknown as Pool; } export const pool = config.useMockDb ? createMockPool() : new Pool(poolConfig); if (!config.useMockDb) { pool.on("error", (err) => { console.error("[db] Unexpected pool error:", err.message); }); } else { console.warn("[db] Mock database enabled (CALENDAR_RUN_MOCK_DB); no PostgreSQL connection is used."); } export async function checkDbConnection(): Promise { if (config.useMockDb) { return true; } try { const client = await pool.connect(); client.release(); return true; } catch { return false; } }