import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { env } from "@/env"; import { PrismaClient } from "@/generated/prisma/client"; import { logger } from "@/lib/logger"; import { DatabaseError } from "./errors"; import { getRequestId } from "./request-context"; const globalForDb = globalThis as unknown as { _db?: DbService }; interface HealthStatus { ok: boolean; latencyMs: number; poolSize: number; activeQueries: number; error?: string; } export class DbService { private readonly client: PrismaClient; private lastHealthCheck = 0; private healthCache: HealthStatus | null = null; private readonly healthTtlMs = 10_000; constructor() { const url = new URL(env.DATABASE_URL); const adapter = new PrismaMariaDb({ host: url.hostname, port: Number(url.port) || 3306, user: decodeURIComponent(url.username), password: decodeURIComponent(url.password), database: url.pathname.replace(/^\//, ""), connectionLimit: env.DATABASE_POOL_SIZE, connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS, acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS, idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS, }); this.client = new PrismaClient({ adapter, log: env.NODE_ENV === "development" ? [ { emit: "event", level: "query" }, { emit: "event", level: "error" }, ] : [{ emit: "event", level: "error" }], }); if (env.NODE_ENV === "development") { this.client.$on("query" as never, (e: unknown) => { const ev = e as { query: string; duration: number }; logger.debug("DB query", { query: ev.query.slice(0, 200), durationMs: ev.duration, requestId: getRequestId(), }); }); } this.client.$on("error" as never, (e: unknown) => { const ev = e as { message: string }; logger.error("DB error", { message: ev.message, requestId: getRequestId(), }); }); } get prisma(): PrismaClient { return this.client; } async health(): Promise { const now = Date.now(); if (this.healthCache && now - this.lastHealthCheck < this.healthTtlMs) { return this.healthCache; } const start = performance.now(); try { await this.client.$queryRaw`SELECT 1`; const latencyMs = Math.round(performance.now() - start); this.healthCache = { ok: true, latencyMs, poolSize: env.DATABASE_POOL_SIZE, activeQueries: 0, }; this.lastHealthCheck = now; return this.healthCache; } catch (cause) { const latencyMs = Math.round(performance.now() - start); const message = cause instanceof Error ? cause.message : "Unknown database error"; this.healthCache = { ok: false, latencyMs, poolSize: env.DATABASE_POOL_SIZE, activeQueries: 0, error: message, }; this.lastHealthCheck = now; return this.healthCache; } } async execute(fn: (client: PrismaClient) => Promise): Promise { this.queryCount++; try { return await fn(this.client); } catch (cause) { throw new DatabaseError("Query failed", cause); } } async transaction( fn: ( tx: Omit< PrismaClient, "$connect" | "$disconnect" | "$on" | "$use" | "$extends" >, ) => Promise, ): Promise { try { return await this.client.$transaction(fn); } catch (cause) { throw new DatabaseError("Transaction failed", cause); } } async rawQuery( strings: TemplateStringsArray, ...values: unknown[] ): Promise { try { return await this.client.$queryRaw(strings, ...values); } catch (cause) { throw new DatabaseError("Raw query failed", cause); } } /** * Execute a raw SQL string with parameterized ? placeholders. * Named "Unsafe" because the caller is responsible for using ? placeholders * and never interpolating user input directly into the query string. */ async executeRawUnsafe(query: string, ...values: unknown[]): Promise { try { return await this.client.$executeRawUnsafe(query, ...values); } catch (cause) { throw new DatabaseError("Execute raw failed", cause); } } } export const db = globalForDb._db ?? (globalForDb._db = new DbService()); if (env.NODE_ENV !== "production") globalForDb._db = db;