157 lines
4.0 KiB
TypeScript
157 lines
4.0 KiB
TypeScript
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<HealthStatus> {
|
|
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<T>(fn: (client: PrismaClient) => Promise<T>): Promise<T> {
|
|
try {
|
|
return await fn(this.client);
|
|
} catch (cause) {
|
|
throw new DatabaseError("Query failed", cause);
|
|
}
|
|
}
|
|
|
|
async transaction<T>(
|
|
fn: (
|
|
tx: Omit<
|
|
PrismaClient,
|
|
"$connect" | "$disconnect" | "$on" | "$use" | "$extends"
|
|
>,
|
|
) => Promise<T>,
|
|
): Promise<T> {
|
|
try {
|
|
return await this.client.$transaction(fn);
|
|
} catch (cause) {
|
|
throw new DatabaseError("Transaction failed", cause);
|
|
}
|
|
}
|
|
|
|
async rawQuery<T>(
|
|
strings: TemplateStringsArray,
|
|
...values: unknown[]
|
|
): Promise<T> {
|
|
try {
|
|
return await this.client.$queryRaw<T>(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<number> {
|
|
try {
|
|
return await this.client.$executeRawUnsafe(query, ...values);
|
|
} catch (cause) {
|
|
throw new DatabaseError("Execute raw failed", cause);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const db = globalForDb._db ?? new DbService();
|
|
if (env.NODE_ENV !== "production") globalForDb._db = db;
|