Files
Epicnabbo-Catalogus-Updated…/src/lib/foundation/database.ts
T

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;