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, timestamp } 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 isSensitive = e.query.includes("PASSWORD("); const sqlWithParams = isSensitive ? e.query : inlineParams(e.query, e.params); if (isSlow) { const formatted = format(sqlWithParams, { language: "mysql", tabWidth: 2 }); const ts = timestamp(); const indented = formatted.split("\n").map((l) => ` ${l}`).join("\n"); writeAll(2, `[${ts}] [WARN ] [db:query] ${e.duration}ms [SLOW]\n${indented}\n`); return; } if (logger.isEnabled("debug")) { const formatted = format(sqlWithParams, { language: "mysql", tabWidth: 2 }); const ts = timestamp(); const indented = formatted.split("\n").map((l) => ` ${l}`).join("\n"); writeAll(1, `[${ts}] [DEBUG] [db:query] ${e.duration}ms\n${indented}\n`); return; } if (logger.isEnabled("info")) { const sql = sqlWithParams.replace(/\s+/g, " ").trim(); const ts = timestamp(); writeAll(1, `[${ts}] [INFO ] [db:query] ${e.duration}ms ${sql}\n`); } } // ── 결과 로깅 ($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[]; 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).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 = timestamp(); 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 }) => { try { const result = await query(args); logResult(model, operation, result); return result; } catch (err) { const label = model ? `${model}.${operation}` : operation; const msg = err instanceof Error ? err.message : String(err); const ts = timestamp(); writeAll(2, `[${ts}] [ERROR] [db:error] ${label} failed: ${msg}\n`); throw err; } }, }, }); // $extends 타입은 PrismaClient의 상위 호환이므로 캐스트 안전 return extendedClient as unknown as PrismaClient; } export const db = globalForPrisma.prisma ?? createPrismaClient(); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = db; }