253 lines
8.1 KiB
TypeScript
253 lines
8.1 KiB
TypeScript
import { writeSync } from "fs";
|
|
|
|
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
|
import { format } from "sql-formatter";
|
|
|
|
import { PrismaClient, Prisma } from "@/lib/generated/prisma/client";
|
|
import { logger } from "@/lib/logger";
|
|
|
|
const globalForPrisma = globalThis as unknown as {
|
|
prisma?: PrismaClient;
|
|
};
|
|
|
|
const SLOW_QUERY_MS = Number(process.env.DB_SLOW_QUERY_MS ?? "1000");
|
|
|
|
// Docker 파이프 환경에서 stdout fd는 non-blocking이라 writeSync가 부분 쓰기를
|
|
// 하거나 EAGAIN을 던질 수 있다. 전부 쓸 때까지 반복해 로그 유실을 막는다.
|
|
function writeAll(fd: number, text: string): void {
|
|
const buf = Buffer.from(text, "utf8");
|
|
let offset = 0;
|
|
while (offset < buf.length) {
|
|
try {
|
|
offset += writeSync(fd, buf, offset, buf.length - offset);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "EAGAIN") continue;
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── SQL 로깅 ($on) ────────────────────────────────────────────────
|
|
|
|
function inlineParams(query: string, rawParams: string): string {
|
|
try {
|
|
const params = JSON.parse(rawParams) as unknown[];
|
|
let idx = 0;
|
|
return query.replace(/\?/g, () => {
|
|
const val = params[idx++];
|
|
if (val === null || val === undefined) return "NULL";
|
|
if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
|
|
if (typeof val === "boolean") return val ? "1" : "0";
|
|
return String(val);
|
|
});
|
|
} catch {
|
|
return query;
|
|
}
|
|
}
|
|
|
|
function onQuery(e: Prisma.QueryEvent): void {
|
|
const isSlow = e.duration >= SLOW_QUERY_MS;
|
|
const level = isSlow ? "warn" : "debug";
|
|
if (!logger.isEnabled(level)) return;
|
|
|
|
const isSensitive = e.query.includes("PASSWORD(");
|
|
const sqlToFormat = isSensitive ? e.query : inlineParams(e.query, e.params);
|
|
const formatted = format(sqlToFormat, { language: "mysql", tabWidth: 2 });
|
|
|
|
const ts = new Date().toISOString();
|
|
const tag = level.toUpperCase().padEnd(5);
|
|
const slowLabel = isSlow ? " [SLOW]" : "";
|
|
const header = `[${ts}] [${tag}] [db:query] ${e.duration}ms${slowLabel}`;
|
|
const indented = formatted
|
|
.split("\n")
|
|
.map((l) => ` ${l}`)
|
|
.join("\n");
|
|
|
|
const out = `${header}\n${indented}\n`;
|
|
writeAll(isSlow ? 2 : 1, out);
|
|
}
|
|
|
|
// ── 결과 로깅 ($extends) ──────────────────────────────────────────
|
|
|
|
const COL_MAX_WIDTH = 40;
|
|
|
|
const SENSITIVE_KEYS = new Set(["Password", "password"]);
|
|
|
|
function safeValue(key: string, value: unknown): string {
|
|
return SENSITIVE_KEYS.has(key) ? "***" : formatValue(value);
|
|
}
|
|
|
|
function formatValue(v: unknown): string {
|
|
if (v === null || v === undefined) return "NULL";
|
|
if (typeof v === "bigint") return v.toString();
|
|
if (v instanceof Date) return v.toISOString();
|
|
if (typeof v === "object") return JSON.stringify(v);
|
|
return String(v);
|
|
}
|
|
|
|
// 한글·한자 등 터미널에서 2칸을 차지하는 유니코드 블록 판별
|
|
function isWide(cp: number): boolean {
|
|
return (
|
|
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
(cp >= 0x2e80 && cp <= 0x303f) || // CJK Radicals Supplement, Kangxi
|
|
(cp >= 0x3040 && cp <= 0x33ff) || // Hiragana, Katakana, Bopomofo, etc.
|
|
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
|
|
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
|
(cp >= 0xa960 && cp <= 0xa97f) || // Hangul Jamo Extended-A
|
|
(cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
|
|
(cp >= 0xd7b0 && cp <= 0xd7ff) || // Hangul Jamo Extended-B
|
|
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
|
(cp >= 0xfe10 && cp <= 0xfe1f) || // Vertical Forms
|
|
(cp >= 0xfe30 && cp <= 0xfe6f) || // CJK Compatibility Forms
|
|
(cp >= 0xff01 && cp <= 0xff60) || // Fullwidth Latin
|
|
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth Currency
|
|
cp >= 0x20000 // CJK Extension B+
|
|
);
|
|
}
|
|
|
|
function displayWidth(s: string): number {
|
|
let w = 0;
|
|
for (const char of s) {
|
|
w += isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
|
|
}
|
|
return w;
|
|
}
|
|
|
|
function truncateStr(s: string, maxWidth: number): string {
|
|
if (displayWidth(s) <= maxWidth) return s;
|
|
let w = 0;
|
|
let result = "";
|
|
for (const char of s) {
|
|
const cw = isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
|
|
if (w + cw > maxWidth - 1) break; // 1칸은 "…"에 예약
|
|
w += cw;
|
|
result += char;
|
|
}
|
|
return result + "…";
|
|
}
|
|
|
|
function padEnd(s: string, width: number): string {
|
|
const truncated = truncateStr(s, width);
|
|
return truncated + " ".repeat(Math.max(0, width - displayWidth(truncated)));
|
|
}
|
|
|
|
function renderTable(headers: string[], rows: string[][]): string {
|
|
const colWidths = headers.map((h, i) =>
|
|
Math.min(
|
|
COL_MAX_WIDTH,
|
|
Math.max(displayWidth(h), ...rows.map((r) => displayWidth(r[i] ?? "")))
|
|
)
|
|
);
|
|
|
|
const rule = (l: string, m: string, r: string, s: string) =>
|
|
l + colWidths.map((w) => s.repeat(w + 2)).join(m) + r;
|
|
|
|
const hRow = "│ " + headers.map((h, i) => padEnd(h, colWidths[i])).join(" │ ") + " │";
|
|
const dRows = rows.map(
|
|
(r) => "│ " + r.map((c, i) => padEnd(c ?? "", colWidths[i])).join(" │ ") + " │"
|
|
);
|
|
|
|
return [
|
|
rule("┌", "┬", "┐", "─"),
|
|
hRow,
|
|
rule("├", "┼", "┤", "─"),
|
|
...dRows,
|
|
rule("└", "┴", "┘", "─"),
|
|
]
|
|
.map((l) => ` ${l}`)
|
|
.join("\n");
|
|
}
|
|
|
|
function buildResultLog(
|
|
model: string | undefined,
|
|
operation: string,
|
|
result: unknown
|
|
): string {
|
|
const label = model ? `${model}.${operation}` : operation;
|
|
|
|
if (typeof result === "number") {
|
|
return `${label} → ${result}`;
|
|
}
|
|
|
|
if (result !== null && typeof result === "object" && "count" in result) {
|
|
return `${label} → count: ${(result as { count: number }).count}`;
|
|
}
|
|
|
|
if (Array.isArray(result)) {
|
|
const total = result.length;
|
|
if (total === 0) return `${label} → 0 rows`;
|
|
|
|
const preview = result.slice(0, 5);
|
|
const countLabel = `${total} row${total !== 1 ? "s" : ""}${total > 5 ? " (showing 5)" : ""}`;
|
|
const moreLabel = total > 5 ? `\n ... ${total - 5} more` : "";
|
|
|
|
if (typeof preview[0] === "object" && preview[0] !== null) {
|
|
const objRows = preview as Record<string, unknown>[];
|
|
const headers = Object.keys(objRows[0]);
|
|
const rows = objRows.map((row) => headers.map((h) => safeValue(h, row[h])));
|
|
return `${label} → ${countLabel}\n${renderTable(headers, rows)}${moreLabel}`;
|
|
}
|
|
|
|
const rows = preview.map((v) => [formatValue(v)]);
|
|
return `${label} → ${countLabel}\n${renderTable(["value"], rows)}${moreLabel}`;
|
|
}
|
|
|
|
if (result !== null && typeof result === "object") {
|
|
const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [
|
|
k,
|
|
safeValue(k, v),
|
|
]);
|
|
return `${label}\n${renderTable(["field", "value"], rows)}`;
|
|
}
|
|
|
|
return `${label} → ${String(result)}`;
|
|
}
|
|
|
|
function logResult(
|
|
model: string | undefined,
|
|
operation: string,
|
|
result: unknown
|
|
): void {
|
|
if (!logger.isEnabled("debug")) return;
|
|
const ts = new Date().toISOString();
|
|
const body = buildResultLog(model, operation, result);
|
|
writeAll(1, `[${ts}] [DEBUG] [db:result] ${body}\n`);
|
|
}
|
|
|
|
// ── PrismaClient 생성 ─────────────────────────────────────────────
|
|
|
|
function createPrismaClient() {
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
|
|
if (!databaseUrl) {
|
|
throw new Error("DATABASE_URL is required to initialize Prisma Client.");
|
|
}
|
|
|
|
const adapter = new PrismaMariaDb(databaseUrl);
|
|
const baseClient = new PrismaClient({
|
|
adapter,
|
|
log: [{ emit: "event", level: "query" }],
|
|
});
|
|
|
|
baseClient.$on("query", onQuery);
|
|
|
|
const extendedClient = baseClient.$extends({
|
|
query: {
|
|
$allOperations: async ({ model, operation, args, query }) => {
|
|
const result = await query(args);
|
|
logResult(model, operation, result);
|
|
return result;
|
|
},
|
|
},
|
|
});
|
|
|
|
// $extends 타입은 PrismaClient의 상위 호환이므로 캐스트 안전
|
|
return extendedClient as unknown as PrismaClient;
|
|
}
|
|
|
|
export const db = globalForPrisma.prisma ?? createPrismaClient();
|
|
|
|
if (process.env.NODE_ENV !== "production") {
|
|
globalForPrisma.prisma = db;
|
|
}
|