UNPKG

@spfn/core

Version:

SPFN Framework Core - File-based routing, transactions, repository pattern

1,661 lines (1,644 loc) 64.1 kB
import { drizzle } from 'drizzle-orm/postgres-js'; import pino from 'pino'; import { existsSync, mkdirSync, accessSync, constants, writeFileSync, unlinkSync, createWriteStream, statSync, readdirSync, renameSync, readFileSync } from 'fs'; import { join, dirname, basename } from 'path'; import { config } from 'dotenv'; import postgres from 'postgres'; import { bigserial, timestamp, pgSchema } from 'drizzle-orm/pg-core'; import { AsyncLocalStorage } from 'async_hooks'; import { randomUUID } from 'crypto'; import { createMiddleware } from 'hono/factory'; import { eq, and } from 'drizzle-orm'; // src/db/manager/factory.ts var PinoAdapter = class _PinoAdapter { logger; constructor(config) { this.logger = pino({ level: config.level, // 기본 필드 base: config.module ? { module: config.module } : void 0 }); } child(module) { const childLogger = new _PinoAdapter({ level: this.logger.level, module }); childLogger.logger = this.logger.child({ module }); return childLogger; } debug(message, context) { this.logger.debug(context || {}, message); } info(message, context) { this.logger.info(context || {}, message); } warn(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.warn({ err: errorOrContext, ...context }, message); } else { this.logger.warn(errorOrContext || {}, message); } } error(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.error({ err: errorOrContext, ...context }, message); } else { this.logger.error(errorOrContext || {}, message); } } fatal(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.fatal({ err: errorOrContext, ...context }, message); } else { this.logger.fatal(errorOrContext || {}, message); } } async close() { } }; // src/logger/types.ts var LOG_LEVEL_PRIORITY = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }; // src/logger/formatters.ts var SENSITIVE_KEYS = [ "password", "passwd", "pwd", "secret", "token", "apikey", "api_key", "accesstoken", "access_token", "refreshtoken", "refresh_token", "authorization", "auth", "cookie", "session", "sessionid", "session_id", "privatekey", "private_key", "creditcard", "credit_card", "cardnumber", "card_number", "cvv", "ssn", "pin" ]; var MASKED_VALUE = "***MASKED***"; function isSensitiveKey(key) { const lowerKey = key.toLowerCase(); return SENSITIVE_KEYS.some((sensitive) => lowerKey.includes(sensitive)); } function maskSensitiveData(data) { if (data === null || data === void 0) { return data; } if (Array.isArray(data)) { return data.map((item) => maskSensitiveData(item)); } if (typeof data === "object") { const masked = {}; for (const [key, value] of Object.entries(data)) { if (isSensitiveKey(key)) { masked[key] = MASKED_VALUE; } else if (typeof value === "object" && value !== null) { masked[key] = maskSensitiveData(value); } else { masked[key] = value; } } return masked; } return data; } var COLORS = { reset: "\x1B[0m", bright: "\x1B[1m", dim: "\x1B[2m", // 로그 레벨 컬러 debug: "\x1B[36m", // cyan info: "\x1B[32m", // green warn: "\x1B[33m", // yellow error: "\x1B[31m", // red fatal: "\x1B[35m", // magenta // 추가 컬러 gray: "\x1B[90m" }; function formatTimestamp(date) { return date.toISOString(); } function formatTimestampHuman(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const seconds = String(date.getSeconds()).padStart(2, "0"); const ms = String(date.getMilliseconds()).padStart(3, "0"); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}`; } function formatError(error) { const lines = []; lines.push(`${error.name}: ${error.message}`); if (error.stack) { const stackLines = error.stack.split("\n").slice(1); lines.push(...stackLines); } return lines.join("\n"); } function formatConsole(metadata, colorize = true) { const parts = []; const timestamp2 = formatTimestampHuman(metadata.timestamp); if (colorize) { parts.push(`${COLORS.gray}[${timestamp2}]${COLORS.reset}`); } else { parts.push(`[${timestamp2}]`); } if (metadata.module) { if (colorize) { parts.push(`${COLORS.dim}[module=${metadata.module}]${COLORS.reset}`); } else { parts.push(`[module=${metadata.module}]`); } } if (metadata.context && Object.keys(metadata.context).length > 0) { Object.entries(metadata.context).forEach(([key, value]) => { const valueStr = typeof value === "string" ? value : String(value); if (colorize) { parts.push(`${COLORS.dim}[${key}=${valueStr}]${COLORS.reset}`); } else { parts.push(`[${key}=${valueStr}]`); } }); } const levelStr = metadata.level.toUpperCase(); if (colorize) { const color = COLORS[metadata.level]; parts.push(`${color}(${levelStr})${COLORS.reset}:`); } else { parts.push(`(${levelStr}):`); } if (colorize) { parts.push(`${COLORS.bright}${metadata.message}${COLORS.reset}`); } else { parts.push(metadata.message); } let output = parts.join(" "); if (metadata.error) { output += "\n" + formatError(metadata.error); } return output; } function formatJSON(metadata) { const obj = { timestamp: formatTimestamp(metadata.timestamp), level: metadata.level, message: metadata.message }; if (metadata.module) { obj.module = metadata.module; } if (metadata.context) { obj.context = metadata.context; } if (metadata.error) { obj.error = { name: metadata.error.name, message: metadata.error.message, stack: metadata.error.stack }; } return JSON.stringify(obj); } // src/logger/logger.ts var Logger = class _Logger { config; module; constructor(config) { this.config = config; this.module = config.module; } /** * Get current log level */ get level() { return this.config.level; } /** * Create child logger (per module) */ child(module) { return new _Logger({ ...this.config, module }); } /** * Debug log */ debug(message, context) { this.log("debug", message, void 0, context); } /** * Info log */ info(message, context) { this.log("info", message, void 0, context); } warn(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.log("warn", message, errorOrContext, context); } else { this.log("warn", message, void 0, errorOrContext); } } error(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.log("error", message, errorOrContext, context); } else { this.log("error", message, void 0, errorOrContext); } } fatal(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.log("fatal", message, errorOrContext, context); } else { this.log("fatal", message, void 0, errorOrContext); } } /** * Log processing (internal) */ log(level, message, error, context) { if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[this.config.level]) { return; } const metadata = { timestamp: /* @__PURE__ */ new Date(), level, message, module: this.module, error, // Mask sensitive information in context to prevent credential leaks context: context ? maskSensitiveData(context) : void 0 }; this.processTransports(metadata); } /** * Process Transports */ processTransports(metadata) { const promises = this.config.transports.filter((transport) => transport.enabled).map((transport) => this.safeTransportLog(transport, metadata)); Promise.all(promises).catch((error) => { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[Logger] Transport error: ${errorMessage} `); }); } /** * Transport log (error-safe) */ async safeTransportLog(transport, metadata) { try { await transport.log(metadata); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[Logger] Transport "${transport.name}" failed: ${errorMessage} `); } } /** * Close all Transports */ async close() { const closePromises = this.config.transports.filter((transport) => transport.close).map((transport) => transport.close()); await Promise.all(closePromises); } }; // src/logger/transports/console.ts var ConsoleTransport = class { name = "console"; level; enabled; colorize; constructor(config) { this.level = config.level; this.enabled = config.enabled; this.colorize = config.colorize ?? true; } async log(metadata) { if (!this.enabled) { return; } if (LOG_LEVEL_PRIORITY[metadata.level] < LOG_LEVEL_PRIORITY[this.level]) { return; } const message = formatConsole(metadata, this.colorize); if (metadata.level === "warn" || metadata.level === "error" || metadata.level === "fatal") { console.error(message); } else { console.log(message); } } }; var FileTransport = class { name = "file"; level; enabled; logDir; maxFileSize; maxFiles; currentStream = null; currentFilename = null; constructor(config) { this.level = config.level; this.enabled = config.enabled; this.logDir = config.logDir; this.maxFileSize = config.maxFileSize ?? 10 * 1024 * 1024; this.maxFiles = config.maxFiles ?? 10; if (!existsSync(this.logDir)) { mkdirSync(this.logDir, { recursive: true }); } } async log(metadata) { if (!this.enabled) { return; } if (LOG_LEVEL_PRIORITY[metadata.level] < LOG_LEVEL_PRIORITY[this.level]) { return; } const message = formatJSON(metadata); const filename = this.getLogFilename(metadata.timestamp); if (this.currentFilename !== filename) { await this.rotateStream(filename); await this.cleanOldFiles(); } else if (this.currentFilename) { await this.checkAndRotateBySize(); } if (this.currentStream) { return new Promise((resolve, reject) => { this.currentStream.write(message + "\n", "utf-8", (error) => { if (error) { process.stderr.write(`[FileTransport] Failed to write log: ${error.message} `); reject(error); } else { resolve(); } }); }); } } /** * 스트림 교체 (날짜 변경 시) */ async rotateStream(filename) { if (this.currentStream) { await this.closeStream(); } const filepath = join(this.logDir, filename); this.currentStream = createWriteStream(filepath, { flags: "a", // append mode encoding: "utf-8" }); this.currentFilename = filename; this.currentStream.on("error", (error) => { process.stderr.write(`[FileTransport] Stream error: ${error.message} `); this.currentStream = null; this.currentFilename = null; }); } /** * 현재 스트림 닫기 */ async closeStream() { if (!this.currentStream) { return; } return new Promise((resolve, reject) => { this.currentStream.end((error) => { if (error) { reject(error); } else { this.currentStream = null; this.currentFilename = null; resolve(); } }); }); } /** * 파일 크기 체크 및 크기 기반 로테이션 */ async checkAndRotateBySize() { if (!this.currentFilename) { return; } const filepath = join(this.logDir, this.currentFilename); if (!existsSync(filepath)) { return; } try { const stats = statSync(filepath); if (stats.size >= this.maxFileSize) { await this.rotateBySize(); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[FileTransport] Failed to check file size: ${errorMessage} `); } } /** * 크기 기반 로테이션 수행 * 예: 2025-01-01.log -> 2025-01-01.1.log, 2025-01-01.1.log -> 2025-01-01.2.log */ async rotateBySize() { if (!this.currentFilename) { return; } await this.closeStream(); const baseName = this.currentFilename.replace(/\.log$/, ""); const files = readdirSync(this.logDir); const relatedFiles = files.filter((file) => file.startsWith(baseName) && file.endsWith(".log")).sort().reverse(); for (const file of relatedFiles) { const match = file.match(/\.(\d+)\.log$/); if (match) { const oldNum = parseInt(match[1], 10); const newNum = oldNum + 1; const oldPath = join(this.logDir, file); const newPath2 = join(this.logDir, `${baseName}.${newNum}.log`); try { renameSync(oldPath, newPath2); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[FileTransport] Failed to rotate file: ${errorMessage} `); } } } const currentPath = join(this.logDir, this.currentFilename); const newPath = join(this.logDir, `${baseName}.1.log`); try { if (existsSync(currentPath)) { renameSync(currentPath, newPath); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[FileTransport] Failed to rotate current file: ${errorMessage} `); } await this.rotateStream(this.currentFilename); } /** * 오래된 로그 파일 정리 * maxFiles 개수를 초과하는 로그 파일 삭제 */ async cleanOldFiles() { try { if (!existsSync(this.logDir)) { return; } const files = readdirSync(this.logDir); const logFiles = files.filter((file) => file.endsWith(".log")).map((file) => { const filepath = join(this.logDir, file); const stats = statSync(filepath); return { file, mtime: stats.mtime }; }).sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); if (logFiles.length > this.maxFiles) { const filesToDelete = logFiles.slice(this.maxFiles); for (const { file } of filesToDelete) { const filepath = join(this.logDir, file); try { unlinkSync(filepath); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[FileTransport] Failed to delete old file "${file}": ${errorMessage} `); } } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write(`[FileTransport] Failed to clean old files: ${errorMessage} `); } } /** * 날짜별 로그 파일명 생성 */ getLogFilename(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}.log`; } async close() { await this.closeStream(); } }; function isFileLoggingEnabled() { return process.env.LOGGER_FILE_ENABLED === "true"; } function getDefaultLogLevel() { const isProduction = process.env.NODE_ENV === "production"; const isDevelopment = process.env.NODE_ENV === "development"; if (isDevelopment) { return "debug"; } if (isProduction) { return "info"; } return "warn"; } function getConsoleConfig() { const isProduction = process.env.NODE_ENV === "production"; return { level: "debug", enabled: true, colorize: !isProduction // Dev: colored output, Production: plain text }; } function getFileConfig() { const isProduction = process.env.NODE_ENV === "production"; return { level: "info", enabled: isProduction, // File logging in production only logDir: process.env.LOG_DIR || "./logs", maxFileSize: 10 * 1024 * 1024, // 10MB maxFiles: 10 }; } function validateDirectoryWritable(dirPath) { if (!existsSync(dirPath)) { try { mkdirSync(dirPath, { recursive: true }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to create log directory "${dirPath}": ${errorMessage}`); } } try { accessSync(dirPath, constants.W_OK); } catch { throw new Error(`Log directory "${dirPath}" is not writable. Please check permissions.`); } const testFile = join(dirPath, ".logger-write-test"); try { writeFileSync(testFile, "test", "utf-8"); unlinkSync(testFile); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Cannot write to log directory "${dirPath}": ${errorMessage}`); } } function validateFileConfig() { if (!isFileLoggingEnabled()) { return; } const logDir = process.env.LOG_DIR; if (!logDir) { throw new Error( "LOG_DIR environment variable is required when LOGGER_FILE_ENABLED=true. Example: LOG_DIR=/var/log/myapp" ); } validateDirectoryWritable(logDir); } function validateSlackConfig() { const webhookUrl = process.env.SLACK_WEBHOOK_URL; if (!webhookUrl) { return; } if (!webhookUrl.startsWith("https://hooks.slack.com/")) { throw new Error( `Invalid SLACK_WEBHOOK_URL: "${webhookUrl}". Slack webhook URLs must start with "https://hooks.slack.com/"` ); } } function validateEmailConfig() { const smtpHost = process.env.SMTP_HOST; const smtpPort = process.env.SMTP_PORT; const emailFrom = process.env.EMAIL_FROM; const emailTo = process.env.EMAIL_TO; const hasAnyEmailConfig = smtpHost || smtpPort || emailFrom || emailTo; if (!hasAnyEmailConfig) { return; } const missingFields = []; if (!smtpHost) missingFields.push("SMTP_HOST"); if (!smtpPort) missingFields.push("SMTP_PORT"); if (!emailFrom) missingFields.push("EMAIL_FROM"); if (!emailTo) missingFields.push("EMAIL_TO"); if (missingFields.length > 0) { throw new Error( `Email transport configuration incomplete. Missing: ${missingFields.join(", ")}. Either set all required fields or remove all email configuration.` ); } const port = parseInt(smtpPort, 10); if (isNaN(port) || port < 1 || port > 65535) { throw new Error( `Invalid SMTP_PORT: "${smtpPort}". Must be a number between 1 and 65535.` ); } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(emailFrom)) { throw new Error(`Invalid EMAIL_FROM format: "${emailFrom}"`); } const recipients = emailTo.split(",").map((e) => e.trim()); for (const email of recipients) { if (!emailRegex.test(email)) { throw new Error(`Invalid email address in EMAIL_TO: "${email}"`); } } } function validateEnvironment() { const nodeEnv = process.env.NODE_ENV; if (!nodeEnv) { process.stderr.write( "[Logger] Warning: NODE_ENV is not set. Defaulting to test environment.\n" ); } } function validateConfig() { try { validateEnvironment(); validateFileConfig(); validateSlackConfig(); validateEmailConfig(); } catch (error) { if (error instanceof Error) { throw new Error(`[Logger] Configuration validation failed: ${error.message}`); } throw error; } } // src/logger/adapters/custom.ts function initializeTransports() { const transports = []; const consoleConfig = getConsoleConfig(); transports.push(new ConsoleTransport(consoleConfig)); const fileConfig = getFileConfig(); if (fileConfig.enabled) { transports.push(new FileTransport(fileConfig)); } return transports; } var CustomAdapter = class _CustomAdapter { logger; constructor(config) { this.logger = new Logger({ level: config.level, module: config.module, transports: initializeTransports() }); } child(module) { const adapter = new _CustomAdapter({ level: this.logger.level, module }); adapter.logger = this.logger.child(module); return adapter; } debug(message, context) { this.logger.debug(message, context); } info(message, context) { this.logger.info(message, context); } warn(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.warn(message, errorOrContext, context); } else { this.logger.warn(message, errorOrContext); } } error(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.error(message, errorOrContext, context); } else { this.logger.error(message, errorOrContext); } } fatal(message, errorOrContext, context) { if (errorOrContext instanceof Error) { this.logger.fatal(message, errorOrContext, context); } else { this.logger.fatal(message, errorOrContext); } } async close() { await this.logger.close(); } }; // src/logger/adapter-factory.ts function createAdapter(type) { const level = getDefaultLogLevel(); switch (type) { case "pino": return new PinoAdapter({ level }); case "custom": return new CustomAdapter({ level }); default: return new PinoAdapter({ level }); } } function getAdapterType() { const adapterEnv = process.env.LOGGER_ADAPTER; if (adapterEnv === "custom" || adapterEnv === "pino") { return adapterEnv; } return "pino"; } function initializeLogger() { validateConfig(); return createAdapter(getAdapterType()); } var logger = initializeLogger(); // src/env/config.ts var ENV_FILE_PRIORITY = [ ".env", // Base configuration (lowest priority) ".env.{NODE_ENV}", // Environment-specific ".env.local", // Local overrides (excluded in test) ".env.{NODE_ENV}.local" // Local environment-specific (highest priority) ]; var TEST_ONLY_FILES = [ ".env.test", ".env.test.local" ]; // src/env/loader.ts var envLogger = logger.child("environment"); var environmentLoaded = false; var cachedLoadResult; function buildFileList(basePath, nodeEnv) { const files = []; if (!nodeEnv) { files.push(join(basePath, ".env")); files.push(join(basePath, ".env.local")); return files; } for (const pattern of ENV_FILE_PRIORITY) { const fileName = pattern.replace("{NODE_ENV}", nodeEnv); if (nodeEnv === "test" && fileName === ".env.local") { continue; } if (nodeEnv === "local" && pattern === ".env.local") { continue; } if (nodeEnv !== "test" && TEST_ONLY_FILES.includes(fileName)) { continue; } files.push(join(basePath, fileName)); } return files; } function loadSingleFile(filePath, debug) { if (!existsSync(filePath)) { if (debug) { envLogger.debug("Environment file not found (optional)", { path: filePath }); } return { success: false, parsed: {}, error: "File not found" }; } try { const result = config({ path: filePath }); if (result.error) { envLogger.warn("Failed to parse environment file", { path: filePath, error: result.error.message }); return { success: false, parsed: {}, error: result.error.message }; } const parsed = result.parsed || {}; if (debug) { envLogger.debug("Environment file loaded successfully", { path: filePath, variables: Object.keys(parsed), count: Object.keys(parsed).length }); } return { success: true, parsed }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; envLogger.error("Error loading environment file", { path: filePath, error: message }); return { success: false, parsed: {}, error: message }; } } function validateRequiredVars(required, debug) { const missing = []; for (const varName of required) { if (!process.env[varName]) { missing.push(varName); } } if (missing.length > 0) { const error = `Required environment variables missing: ${missing.join(", ")}`; envLogger.error("Environment validation failed", { missing, required }); throw new Error(error); } if (debug) { envLogger.debug("Required environment variables validated", { required, allPresent: true }); } } function loadEnvironment(options = {}) { const { basePath = process.cwd(), customPaths = [], debug = false, nodeEnv = process.env.NODE_ENV || "", required = [], useCache = true } = options; if (useCache && environmentLoaded && cachedLoadResult) { if (debug) { envLogger.debug("Returning cached environment", { loaded: cachedLoadResult.loaded.length, variables: Object.keys(cachedLoadResult.parsed).length }); } return cachedLoadResult; } if (debug) { envLogger.debug("Loading environment variables", { basePath, nodeEnv, customPaths, required }); } const result = { success: true, loaded: [], failed: [], parsed: {}, warnings: [] }; const standardFiles = buildFileList(basePath, nodeEnv); const allFiles = [...standardFiles, ...customPaths]; if (debug) { envLogger.debug("Environment files to load", { standardFiles, customPaths, total: allFiles.length }); } const reversedFiles = [...allFiles].reverse(); for (const filePath of reversedFiles) { const fileResult = loadSingleFile(filePath, debug); if (fileResult.success) { result.loaded.push(filePath); Object.assign(result.parsed, fileResult.parsed); if (fileResult.parsed["NODE_ENV"]) { const fileName = filePath.split("/").pop() || filePath; result.warnings.push( `NODE_ENV found in ${fileName}. It's recommended to set NODE_ENV via CLI (e.g., 'spfn dev', 'spfn build') instead of .env files for consistent environment behavior.` ); } } else if (fileResult.error) { result.failed.push({ path: filePath, reason: fileResult.error }); } } if (debug || result.loaded.length > 0) { envLogger.info("Environment loading complete", { loaded: result.loaded.length, failed: result.failed.length, variables: Object.keys(result.parsed).length, files: result.loaded }); } if (required.length > 0) { try { validateRequiredVars(required, debug); } catch (error) { result.success = false; result.errors = [ error instanceof Error ? error.message : "Validation failed" ]; throw error; } } if (result.warnings.length > 0) { for (const warning of result.warnings) { envLogger.warn(warning); } } environmentLoaded = true; cachedLoadResult = result; return result; } // src/errors/database-errors.ts var DatabaseError = class extends Error { statusCode; details; timestamp; constructor(message, statusCode = 500, details) { super(message); this.name = "DatabaseError"; this.statusCode = statusCode; this.details = details; this.timestamp = /* @__PURE__ */ new Date(); Error.captureStackTrace(this, this.constructor); } /** * Serialize error for API response */ toJSON() { return { name: this.name, message: this.message, statusCode: this.statusCode, details: this.details, timestamp: this.timestamp.toISOString() }; } }; var ConnectionError = class extends DatabaseError { constructor(message, details) { super(message, 503, details); this.name = "ConnectionError"; } }; var QueryError = class extends DatabaseError { constructor(message, statusCode = 500, details) { super(message, statusCode, details); this.name = "QueryError"; } }; var ConstraintViolationError = class extends QueryError { constructor(message, details) { super(message, 400, details); this.name = "ConstraintViolationError"; } }; var TransactionError = class extends DatabaseError { constructor(message, statusCode = 500, details) { super(message, statusCode, details); this.name = "TransactionError"; } }; var DeadlockError = class extends TransactionError { constructor(message, details) { super(message, 409, details); this.name = "DeadlockError"; } }; var DuplicateEntryError = class extends QueryError { constructor(field, value) { super(`${field} '${value}' already exists`, 409, { field, value }); this.name = "DuplicateEntryError"; } }; // src/db/postgres-errors.ts function parseUniqueViolation(message) { const patterns = [ // Standard format: Key (field)=(value) /Key \(([^)]+)\)=\(([^)]+)\)/i, // With quotes: Key ("field")=('value') /Key \(["']?([^)"']+)["']?\)=\(["']?([^)"']+)["']?\)/i, // Alternative format /Key `([^`]+)`=`([^`]+)`/i ]; for (const pattern of patterns) { const match = message.match(pattern); if (match) { const field = match[1].trim().replace(/["'`]/g, ""); const value = match[2].trim().replace(/["'`]/g, ""); return { field, value }; } } return null; } function fromPostgresError(error) { const code = error?.code; const message = error?.message || "Database error occurred"; switch (code) { // Class 08 — Connection Exception case "08000": // connection_exception case "08001": // sqlclient_unable_to_establish_sqlconnection case "08003": // connection_does_not_exist case "08004": // sqlserver_rejected_establishment_of_sqlconnection case "08006": // connection_failure case "08007": // transaction_resolution_unknown case "08P01": return new ConnectionError(message, { code }); // Class 23 — Integrity Constraint Violation case "23000": // integrity_constraint_violation case "23001": return new ConstraintViolationError(message, { code, constraint: "integrity" }); case "23502": return new ConstraintViolationError(message, { code, constraint: "not_null" }); case "23503": return new ConstraintViolationError(message, { code, constraint: "foreign_key" }); case "23505": const parsed = parseUniqueViolation(message); if (parsed) { return new DuplicateEntryError(parsed.field, parsed.value); } return new DuplicateEntryError("field", "value"); case "23514": return new ConstraintViolationError(message, { code, constraint: "check" }); // Class 40 — Transaction Rollback case "40000": // transaction_rollback case "40001": // serialization_failure case "40002": // transaction_integrity_constraint_violation case "40003": return new TransactionError(message, 500, { code }); case "40P01": return new DeadlockError(message, { code }); // Class 42 — Syntax Error or Access Rule Violation case "42000": // syntax_error_or_access_rule_violation case "42601": // syntax_error case "42501": // insufficient_privilege case "42602": // invalid_name case "42622": // name_too_long case "42701": // duplicate_column case "42702": // ambiguous_column case "42703": // undefined_column case "42704": // undefined_object case "42P01": // undefined_table case "42P02": return new QueryError(message, 400, { code }); // Class 53 — Insufficient Resources case "53000": // insufficient_resources case "53100": // disk_full case "53200": // out_of_memory case "53300": return new ConnectionError(message, { code }); // Class 57 — Operator Intervention case "57000": // operator_intervention case "57014": // query_canceled case "57P01": // admin_shutdown case "57P02": // crash_shutdown case "57P03": return new ConnectionError(message, { code }); // Default: Unknown error default: return new QueryError(message, 500, { code }); } } // src/db/manager/connection.ts var dbLogger = logger.child("database"); function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function createDatabaseConnection(connectionString, poolConfig, retryConfig) { let lastError; for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) { try { const client = postgres(connectionString, { max: poolConfig.max, idle_timeout: poolConfig.idleTimeout }); await client`SELECT 1 as test`; if (attempt > 0) { dbLogger.info(`Database connected successfully after ${attempt} retries`); } else { dbLogger.info("Database connected successfully"); } return client; } catch (error) { lastError = fromPostgresError(error); if (attempt < retryConfig.maxRetries) { const delayMs = Math.min( retryConfig.initialDelay * Math.pow(retryConfig.factor, attempt), retryConfig.maxDelay ); dbLogger.warn( `Connection failed (attempt ${attempt + 1}/${retryConfig.maxRetries + 1}), retrying in ${delayMs}ms...`, lastError, { attempt: attempt + 1, maxRetries: retryConfig.maxRetries + 1, delayMs } ); await delay(delayMs); } } } const errorMessage = `Failed to connect to database after ${retryConfig.maxRetries + 1} attempts: ${lastError?.message || "Unknown error"}`; throw new ConnectionError(errorMessage); } async function checkConnection(client) { try { await client`SELECT 1 as health_check`; return true; } catch (error) { dbLogger.error("Database health check failed", error); return false; } } // src/db/manager/config.ts function parseEnvNumber(key, prodDefault, devDefault) { const isProduction = process.env.NODE_ENV === "production"; const envValue = parseInt(process.env[key] || "", 10); return isNaN(envValue) ? isProduction ? prodDefault : devDefault : envValue; } function parseEnvBoolean(key, defaultValue) { const value = process.env[key]; if (value === void 0) return defaultValue; return value.toLowerCase() === "true"; } function getPoolConfig(options) { return { max: options?.max ?? parseEnvNumber("DB_POOL_MAX", 20, 10), idleTimeout: options?.idleTimeout ?? parseEnvNumber("DB_POOL_IDLE_TIMEOUT", 30, 20) }; } function getRetryConfig() { return { maxRetries: parseEnvNumber("DB_RETRY_MAX", 5, 3), initialDelay: parseEnvNumber("DB_RETRY_INITIAL_DELAY", 100, 50), maxDelay: parseEnvNumber("DB_RETRY_MAX_DELAY", 1e4, 5e3), factor: parseEnvNumber("DB_RETRY_FACTOR", 2, 2) }; } function buildHealthCheckConfig(options) { return { enabled: options?.enabled ?? parseEnvBoolean("DB_HEALTH_CHECK_ENABLED", true), interval: options?.interval ?? parseEnvNumber("DB_HEALTH_CHECK_INTERVAL", 6e4, 6e4), reconnect: options?.reconnect ?? parseEnvBoolean("DB_HEALTH_CHECK_RECONNECT", true), maxRetries: options?.maxRetries ?? parseEnvNumber("DB_HEALTH_CHECK_MAX_RETRIES", 3, 3), retryInterval: options?.retryInterval ?? parseEnvNumber("DB_HEALTH_CHECK_RETRY_INTERVAL", 5e3, 5e3) }; } function buildMonitoringConfig(options) { const isDevelopment = process.env.NODE_ENV !== "production"; return { enabled: options?.enabled ?? parseEnvBoolean("DB_MONITORING_ENABLED", isDevelopment), slowThreshold: options?.slowThreshold ?? parseEnvNumber("DB_MONITORING_SLOW_THRESHOLD", 1e3, 1e3), logQueries: options?.logQueries ?? parseEnvBoolean("DB_MONITORING_LOG_QUERIES", false) }; } // src/db/manager/factory.ts var dbLogger2 = logger.child("database"); function hasDatabaseConfig() { return !!(process.env.DATABASE_URL || process.env.DATABASE_WRITE_URL || process.env.DATABASE_READ_URL); } function detectDatabasePattern() { if (process.env.DATABASE_WRITE_URL && process.env.DATABASE_READ_URL) { return { type: "write-read", write: process.env.DATABASE_WRITE_URL, read: process.env.DATABASE_READ_URL }; } if (process.env.DATABASE_URL && process.env.DATABASE_REPLICA_URL) { return { type: "legacy", primary: process.env.DATABASE_URL, replica: process.env.DATABASE_REPLICA_URL }; } if (process.env.DATABASE_URL) { return { type: "single", url: process.env.DATABASE_URL }; } if (process.env.DATABASE_WRITE_URL) { return { type: "single", url: process.env.DATABASE_WRITE_URL }; } return { type: "none" }; } async function createWriteReadClients(writeUrl, readUrl, poolConfig, retryConfig) { const writeClient = await createDatabaseConnection(writeUrl, poolConfig, retryConfig); const readClient = await createDatabaseConnection(readUrl, poolConfig, retryConfig); return { write: drizzle(writeClient), read: drizzle(readClient), writeClient, readClient }; } async function createSingleClient(url, poolConfig, retryConfig) { const client = await createDatabaseConnection(url, poolConfig, retryConfig); const db = drizzle(client); return { write: db, read: db, writeClient: client, readClient: client }; } async function createDatabaseFromEnv(options) { if (!hasDatabaseConfig()) { dbLogger2.debug("No DATABASE_URL found, loading environment variables"); const result = loadEnvironment({ debug: true }); dbLogger2.debug("Environment variables loaded", { success: result.success, loaded: result.loaded.length, hasDatabaseUrl: !!process.env.DATABASE_URL, hasWriteUrl: !!process.env.DATABASE_WRITE_URL, hasReadUrl: !!process.env.DATABASE_READ_URL }); } if (!hasDatabaseConfig()) { dbLogger2.warn("No database configuration found", { cwd: process.cwd(), nodeEnv: process.env.NODE_ENV, checkedVars: ["DATABASE_URL", "DATABASE_WRITE_URL", "DATABASE_READ_URL"] }); return { write: void 0, read: void 0 }; } try { const poolConfig = getPoolConfig(options?.pool); const retryConfig = getRetryConfig(); const pattern = detectDatabasePattern(); switch (pattern.type) { case "write-read": dbLogger2.debug("Using write-read pattern", { write: pattern.write.replace(/:[^:@]+@/, ":***@"), read: pattern.read.replace(/:[^:@]+@/, ":***@") }); return await createWriteReadClients( pattern.write, pattern.read, poolConfig, retryConfig ); case "legacy": dbLogger2.debug("Using legacy replica pattern", { primary: pattern.primary.replace(/:[^:@]+@/, ":***@"), replica: pattern.replica.replace(/:[^:@]+@/, ":***@") }); return await createWriteReadClients( pattern.primary, pattern.replica, poolConfig, retryConfig ); case "single": dbLogger2.debug("Using single database pattern", { url: pattern.url.replace(/:[^:@]+@/, ":***@") }); return await createSingleClient(pattern.url, poolConfig, retryConfig); case "none": dbLogger2.warn("No database pattern detected"); return { write: void 0, read: void 0 }; } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; dbLogger2.error("Failed to create database connection", { error: message, stage: "initialization", hasWriteUrl: !!process.env.DATABASE_WRITE_URL, hasReadUrl: !!process.env.DATABASE_READ_URL, hasUrl: !!process.env.DATABASE_URL, hasReplicaUrl: !!process.env.DATABASE_REPLICA_URL }); throw new Error(`Database connection failed: ${message}`, { cause: error }); } } // src/db/manager/global-state.ts var getWriteInstance = () => globalThis.__SPFN_DB_WRITE__; var setWriteInstance = (instance) => { globalThis.__SPFN_DB_WRITE__ = instance; }; var getReadInstance = () => globalThis.__SPFN_DB_READ__; var setReadInstance = (instance) => { globalThis.__SPFN_DB_READ__ = instance; }; var getWriteClient = () => globalThis.__SPFN_DB_WRITE_CLIENT__; var setWriteClient = (client) => { globalThis.__SPFN_DB_WRITE_CLIENT__ = client; }; var getReadClient = () => globalThis.__SPFN_DB_READ_CLIENT__; var setReadClient = (client) => { globalThis.__SPFN_DB_READ_CLIENT__ = client; }; var getHealthCheckInterval = () => globalThis.__SPFN_DB_HEALTH_CHECK__; var setHealthCheckInterval = (interval) => { globalThis.__SPFN_DB_HEALTH_CHECK__ = interval; }; var setMonitoringConfig = (config) => { globalThis.__SPFN_DB_MONITORING__ = config; }; // src/db/manager/health-check.ts var dbLogger3 = logger.child("database"); function startHealthCheck(config, options, getDatabase2, closeDatabase2) { const healthCheck = getHealthCheckInterval(); if (healthCheck) { dbLogger3.debug("Health check already running"); return; } dbLogger3.info("Starting database health check", { interval: `${config.interval}ms`, reconnect: config.reconnect }); const interval = setInterval(async () => { try { const write = getDatabase2("write"); const read = getDatabase2("read"); if (write) { await write.execute("SELECT 1"); } if (read && read !== write) { await read.execute("SELECT 1"); } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; dbLogger3.error("Database health check failed", { error: message }); if (config.reconnect) { await attemptReconnection(config, options, closeDatabase2); } } }, config.interval); setHealthCheckInterval(interval); } async function attemptReconnection(config, options, closeDatabase2) { dbLogger3.warn("Attempting database reconnection", { maxRetries: config.maxRetries, retryInterval: `${config.retryInterval}ms` }); for (let attempt = 1; attempt <= config.maxRetries; attempt++) { try { dbLogger3.debug(`Reconnection attempt ${attempt}/${config.maxRetries}`); await closeDatabase2(); await new Promise((resolve) => setTimeout(resolve, config.retryInterval)); const result = await createDatabaseFromEnv(options); if (result.write) { await result.write.execute("SELECT 1"); setWriteInstance(result.write); setReadInstance(result.read); setWriteClient(result.writeClient); setReadClient(result.readClient); dbLogger3.info("Database reconnection successful", { attempt }); return; } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; dbLogger3.error(`Reconnection attempt ${attempt} failed`, { error: message, attempt, maxRetries: config.maxRetries }); if (attempt === config.maxRetries) { dbLogger3.error("Max reconnection attempts reached, giving up"); } } } } function stopHealthCheck() { const healthCheck = getHealthCheckInterval(); if (healthCheck) { clearInterval(healthCheck); setHealthCheckInterval(void 0); dbLogger3.info("Database health check stopped"); } } // src/db/manager/manager.ts var dbLogger4 = logger.child("database"); function getDatabase(type) { const writeInst = getWriteInstance(); const readInst = getReadInstance(); dbLogger4.debug(`getDatabase() called with type=${type}, writeInstance=${!!writeInst}, readInstance=${!!readInst}`); if (type === "read") { return readInst ?? writeInst; } return writeInst; } function setDatabase(write, read) { setWriteInstance(write); setReadInstance(read ?? write); } async function initDatabase(options) { const writeInst = getWriteInstance(); if (writeInst) { dbLogger4.debug("Database already initialized"); return { write: writeInst, read: getReadInstance() }; } const result = await createDatabaseFromEnv(options); if (result.write) { try { await result.write.execute("SELECT 1"); if (result.read && result.read !== result.write) { await result.read.execute("SELECT 1"); } setWriteInstance(result.write); setReadInstance(result.read); setWriteClient(result.writeClient); setReadClient(result.readClient); const hasReplica = result.read && result.read !== result.write; dbLogger4.info( hasReplica ? "Database connected (Primary + Replica)" : "Database connected" ); const healthCheckConfig = buildHealthCheckConfig(options?.healthCheck); if (healthCheckConfig.enabled) { startHealthCheck(healthCheckConfig, options, getDatabase, closeDatabase); } const monConfig = buildMonitoringConfig(options?.monitoring); setMonitoringConfig(monConfig); if (monConfig.enabled) { dbLogger4.info("Database query monitoring enabled", { slowThreshold: `${monConfig.slowThreshold}ms`, logQueries: monConfig.logQueries }); } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; dbLogger4.error("Database connection failed", { error: message }); await closeDatabase(); throw new Error(`Database connection test failed: ${message}`, { cause: error }); } } else { dbLogger4.warn("No database configuration found"); dbLogger4.warn("Set DATABASE_URL environment variable to enable database"); } return { write: getWriteInstance(), read: getReadInstance() }; } async function closeDatabase() { const writeInst = getWriteInstance(); const readInst = getReadInstance(); if (!writeInst && !readInst) { dbLogger4.debug("No database connections to close"); return; } stopHealthCheck(); try { const closePromises = []; const writeC = getWriteClient(); if (writeC) { dbLogger4.debug("Closing write connection..."); closePromises.push( writeC.end({ timeout: 5 }).then(() => dbLogger4.debug("Write connection closed")).catch((err) => dbLogger4.error("Error closing write connection", err)) ); } const readC = getReadClient(); if (readC && readC !== writeC) { dbLogger4.debug("Closing read connection..."); closePromises.push( readC.end({ timeout: 5 }).then(() => dbLogger4.debug("Read connection closed")).catch((err) => dbLogger4.error("Error closing read connection", err)) ); } await Promise.all(closePromises); dbLogger4.info("All database connections closed"); } catch (error) { dbLogger4.error("Error during database cleanup", error); throw error; } finally { setWriteInstance(void 0); setReadInstance(void 0); setWriteClient(void 0); setReadClient(void 0); setMonitoringConfig(void 0); } } function getDatabaseInfo() { const writeInst = getWriteInstance(); const readInst = getReadInstance(); return { hasWrite: !!writeInst, hasRead: !!readInst, isReplica: !!(readInst && readInst !== writeInst) }; } function expandGlobPattern(pattern) { if (!pattern.includes("*")) { return existsSync(pattern) ? [pattern] : []; } const files = []; if (pattern.includes("**")) { const [baseDir, ...rest] = pattern.split("**"); const extension = rest.join("").replace(/[\/\\]\*\./g, "").trim(); const scanRecursive = (dir) => { if (!existsSync(dir)) return; try { const entries = readdirSync(dir); for (const entry of entries) { const fullPath = join(dir, entry); try { const stat = statSync(fullPath); if (stat.isDirectory()) { scanRecursive(fullPath); } else if (stat.isFile()) { if (!extension || fullPath.endsWith(extension)) { files.push(fullPath); } } } catch { } } } catch { } }; scanRecursive(baseDir.trim() || "."); } else if (pattern.includes("*")) { const dir = dirname(pattern); const filePattern = basename(pattern); if (!existsSync(dir)) return []; try { const entries = readdirSync(dir); for (const entry of entries) { const fullPath = join(dir, entry); try { const stat = statSync(fullPath); if (stat.isFile()) { if (filePattern === "*" || filePattern.startsWith("*.") && entry.endsWith(filePattern.slice(1))) { files.push(fullPath); } } } catch { } } } catch { } } return files; } function discoverPackageSchemas(cwd) { const schemas = []; const nodeModulesPath = join(cwd, "node_modules"); if (!existsSync(nodeModulesPath)) { return schemas; } const projectPkgPath = join(cwd, "package.json"); let directDeps = /* @__PURE__ */ new Set(); if (existsSync(projectPkgPath)) { try { const projectPkg = JSON.parse(readFileSync(projectPkgPath, "utf-8")); directDeps = /* @__PURE__ */ new Set([ ...Object.keys(projectPkg.dependencies || {}), ...Object.keys(projectPkg.devDependencies || {}) ]); } catch (error) { } } const checkPackage = (_pkgName, pkgPath) => { const pkgJsonPath = join(pkgPath, "package.json"); if (!existsSync(pkgJsonPath)) return; try { const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); if (pkgJson.spfn?.schemas) { const packageSchemas = Array.isArray(pkgJson.spfn.schemas) ? pkgJson.spfn.schemas : [pkgJson.spfn.schemas]; for (const schema of packageSchemas) { const absolutePath = join(pkgPath, schema); const expandedFiles = expandGlobPattern(absolutePath); const schemaFi