diff --git a/apps/server/.env.development b/apps/server/.env.development index 99ff3209..26633961 100644 --- a/apps/server/.env.development +++ b/apps/server/.env.development @@ -1,10 +1,21 @@ +NODE_ENV=development +HOST=0.0.0.0 +PORT=3002 +FRONTEND_URL=http://localhost:5173 + + +DB_DIALECT=mysql DB_HOST=localhost +DB_PORT=3306 +DB_NAME=uecko_erp DB_USER=rodax DB_PASSWORD=rodax -DB_NAME=uecko_erp -DB_PORT=3306 -PORT=3002 +DB_LOGGING=false +DB_SYNC_MODE=alter + +APP_TIMEZONE=Europe/Madrid +TRUST_PROXY=0 JWT_SECRET=supersecretkey JWT_ACCESS_EXPIRATION=1h diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 00000000..472274bd --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,25 @@ +NODE_ENV=development +HOST=0.0.0.0 +PORT=3002 +FRONTEND_URL=http://localhost:5173 + +# Base de datos (opción 1: URL) +# DATABASE_URL=postgres://user:pass@localhost:5432/dbname + +# Base de datos (opción 2: parámetros sueltos) +DB_DIALECT=mysql +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=uecko_erp +DB_USER=rodax +DB_PASSWORD=rodax + +DB_LOGGING=false +DB_SYNC_MODE=alter + +APP_TIMEZONE=Europe/Madrid +TRUST_PROXY=0 + +JWT_SECRET=supersecretkey +JWT_ACCESS_EXPIRATION=1h +JWT_REFRESH_EXPIRATION=7d \ No newline at end of file diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d74354ae..7f4654e5 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,69 +1,89 @@ -//import { initPackages } from "@/core/package-loader"; -import cors from "cors"; -import dotenv from "dotenv"; +import { logger } from "@/lib/logger"; +import cors, { CorsOptions } from "cors"; import express, { Application } from "express"; import helmet from "helmet"; import responseTime from "response-time"; -import { ENV } from "./config"; -import { logger } from "./lib/logger"; -dotenv.config(); +// ❗️ No cargamos dotenv aquí. Debe hacerse en el entrypoint o en ./config. +// dotenv.config(); +import { ENV } from "./config"; export function createApp(): Application { const app = express(); app.set("port", process.env.PORT ?? 3002); - // secure apps by setting various HTTP headers + // Oculta la cabecera x-powered-by app.disable("x-powered-by"); - // Middlewares + // Desactiva ETag correctamente a nivel de Express + app.set("etag", false); + + // ─────────────────────────────────────────────────────────────────────────── + // Parsers app.use(express.json()); app.use(express.text()); app.use(express.urlencoded({ extended: true })); - app.use(responseTime()); // set up the response-time middleware + // Métrica de tiempo de respuesta + app.use(responseTime()); - // enable CORS - Cross Origin Resource Sharing - app.use( - cors({ - origin: ENV.NODE_ENV === "development" ? "*" : process.env.FRONTEND_URL, - methods: "GET,POST,PUT,DELETE,OPTIONS", - credentials: true, + // ─────────────────────────────────────────────────────────────────────────── + // CORS + const devCors: CorsOptions = { + // En desarrollo reflejamos el Origin entrante (permite credenciales) + origin: true, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + exposedHeaders: [ + "Content-Disposition", + "Content-Type", + "Content-Length", + "X-Total-Count", + "Pagination-Count", + "Pagination-Page", + "Pagination-Limit", + ], + }; - exposedHeaders: [ - "Access-Control-Allow-Headers", - "Access-Control-Allow-Origin", - "Content-Disposition", - "Content-Type", - "Content-Length", - "X-Total-Count", - "Pagination-Count", - "Pagination-Page", - "Pagination-Limit", - ], - }) - ); + const prodCors: CorsOptions = { + origin: ENV.FRONTEND_URL, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + exposedHeaders: [ + "Content-Disposition", + "Content-Type", + "Content-Length", + "X-Total-Count", + "Pagination-Count", + "Pagination-Page", + "Pagination-Limit", + ], + }; - // secure apps by setting various HTTP headers + app.use(cors(ENV.NODE_ENV === "development" ? devCors : prodCors)); + + // ─────────────────────────────────────────────────────────────────────────── + // Seguridad HTTP app.use(helmet()); - // Middleware global para desactivar la caché en todas las rutas - app.use((req, res, next) => { + // ─────────────────────────────────────────────────────────────────────────── + // Política de caché por defecto (no almacenamiento) + app.use((_, res, next) => { res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); - res.setHeader("etag", "false"); - next(); // Continúa con la siguiente función middleware o la ruta - }); - - // Inicializar Auth Provider - app.use((req, res, next) => { - //authProvider.initialize(); next(); }); + // Inicializar Auth Provider (placeholder) + app.use((_, __, next) => { + // authProvider.initialize(); + next(); + }); + + // Logging de cada request app.use((req, _, next) => { - logger.info(`▶️ Incoming request ${req.method} to ${req.path}`); + logger.info(`▶️ Incoming request ${req.method} to ${req.path}`); next(); }); diff --git a/apps/server/src/config/config-helpers.ts b/apps/server/src/config/config-helpers.ts new file mode 100644 index 00000000..4f862e80 --- /dev/null +++ b/apps/server/src/config/config-helpers.ts @@ -0,0 +1,12 @@ +export function asNumber(value: string | undefined, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} +export function asBoolean(value: string | undefined, fallback: boolean): boolean { + if (value === undefined) return fallback; + return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); +} +export function required(name: string, value: string | undefined): string { + if (!value) throw new Error(`Missing required environment variable: ${name}`); + return value; +} diff --git a/apps/server/src/config/database.ts b/apps/server/src/config/database.ts index e8675582..06d5a033 100644 --- a/apps/server/src/config/database.ts +++ b/apps/server/src/config/database.ts @@ -1,81 +1,84 @@ import { logger } from "@/lib/logger"; -import dotenv from "dotenv"; import { Sequelize } from "sequelize"; +import { ENV } from "./index"; -dotenv.config(); - -let sequelizeInstance: Sequelize | null = null; - -export function getDatabase(): Sequelize { - if (sequelizeInstance) { - return sequelizeInstance; - } - - sequelizeInstance = new Sequelize( - process.env.DB_NAME as string, - process.env.DB_USER as string, - process.env.DB_PASSWORD as string, - { - host: process.env.DB_HOST as string, - dialect: "mysql", - port: Number.parseInt(process.env.DB_PORT || "3306", 10), - dialectOptions: { - multipleStatements: true, - dateStrings: true, - typeCast: true, - }, - pool: { - max: 10, - min: 0, - acquire: 30000, - idle: 10000, - }, - logQueryParameters: true, - logging: process.env.DB_LOGGING === "true" ? logger.debug : false, - define: { - charset: "utf8mb4", - collate: "utf8mb4_unicode_ci", - underscored: true, - timestamps: true, - }, - } - ); - - return sequelizeInstance; -} - -export async function tryConnectToDatabase() { - const database = getDatabase(); - - if (!database) { - const error = new Error("❌ Database not found."); - logger.error(error.message, { - label: "tryConnectToDatabase", - }); - throw error; - } - logger.info("🔸 Connecting to database...", { - label: "tryConnectToDatabase", - }); +/** + * Crea la instancia de Sequelize según ENV (DATABASE_URL o parámetros sueltos), + * autentica la conexión y devuelve la instancia lista para usar. + */ +export async function tryConnectToDatabase(): Promise { + const sequelize = buildSequelize(); try { - await database.authenticate(); - - logger.info(`✔️${" "}Database connection established successfully.`, { - label: "tryConnectToDatabase", - meta: { - host: process.env.DB_HOST, - port: process.env.DB_PORT, - database: process.env.DB_NAME, - user: process.env.DB_USER, - }, + await sequelize.authenticate(); + logger.info("✔️ Database connection established", { + label: "database", + // Info no sensible: + dialect: sequelize.getDialect(), + host: (sequelize.config.host ?? "url") as string, + database: (sequelize.config.database ?? "url") as string, }); - return database; - } catch (error) { - logger.error(`❌ Unable to connect to the database: ${(error as Error).message}`, { - error, - label: "tryConnectToDatabase", + return sequelize; + } catch (error: any) { + logger.error(`❌ Unable to connect to the database: ${error?.message ?? error}`, { + label: "database", }); + // Cerramos por si quedó algo abierto + try { + await sequelize.close(); + } catch { + /* noop */ + } throw error; } } + +/** + * Cierra la instancia de Sequelize (para integrar con el shutdown). + */ +export async function closeDatabase(sequelize: Sequelize): Promise { + try { + await sequelize.close(); + logger.info("Database connection closed", { label: "database" }); + } catch (error: any) { + logger.error(`Error while closing database: ${error?.message ?? error}`, { + label: "database", + }); + } +} + +function buildSequelize(): Sequelize { + const common = { + logging: ENV.DB_LOGGING + ? (msg: any) => logger.debug(String(msg), { label: "sequelize" }) + : false, + timezone: ENV.APP_TIMEZONE, + pool: { + max: 10, + min: 0, + idle: 10_000, + acquire: 30_000, + }, + // dialectOptions: { /* según dialecto (ssl, etc.) */ }, + } as const; + + if (ENV.DATABASE_URL && ENV.DATABASE_URL.trim() !== "") { + // URL completa (recomendada p.ej. en Postgres/MariaDB) + return new Sequelize(ENV.DATABASE_URL, common); + } + + // Parámetros sueltos (asegurar requeridos mínimos) + if (!ENV.DB_DIALECT) { + throw new Error("DB_DIALECT is required when DATABASE_URL is not provided"); + } + if (!ENV.DB_NAME || !ENV.DB_USER) { + throw new Error("DB_NAME and DB_USER are required when DATABASE_URL is not provided"); + } + + return new Sequelize(ENV.DB_NAME, ENV.DB_USER, ENV.DB_PASSWORD, { + host: ENV.DB_HOST, + port: ENV.DB_PORT, + dialect: ENV.DB_DIALECT, + ...common, + }); +} diff --git a/apps/server/src/config/index.ts b/apps/server/src/config/index.ts index a3a59312..5b3e4afc 100644 --- a/apps/server/src/config/index.ts +++ b/apps/server/src/config/index.ts @@ -1,12 +1,67 @@ import dotenv from "dotenv"; -export * from "./database"; +import { asBoolean, asNumber, required } from "./config-helpers"; -// Carga variables de entorno desde el archivo .env +// Carga de variables de entorno (.env). Si ya están en el entorno, no se sobreescriben. dotenv.config(); -// Exporta una configuración centralizada, aplicando valores por defecto donde sea necesario +type NodeEnv = "development" | "test" | "production"; +type DbSyncMode = "none" | "alter" | "force"; +type DbDialect = "postgres" | "mysql" | "mariadb" | "mssql" | "sqlite"; + +const NODE_ENV = (process.env.NODE_ENV as NodeEnv) ?? "development"; +const isProd = NODE_ENV === "production"; +const isDev = NODE_ENV === "development"; + +const HOST = process.env.HOST ?? "0.0.0.0"; +const PORT = asNumber(process.env.PORT, 3002); + +// En producción exigimos FRONTEND_URL definido (según requisitos actuales). +const FRONTEND_URL = isProd + ? required("FRONTEND_URL", process.env.FRONTEND_URL) + : (process.env.FRONTEND_URL ?? "http://localhost:5173"); + +// Base de datos (dos modos: URL o parámetros sueltos) +const DATABASE_URL = process.env.DATABASE_URL; // p.ej. postgres://user:pass@host:5432/dbname + +const DB_DIALECT = (process.env.DB_DIALECT as DbDialect | undefined) ?? undefined; +const DB_HOST = process.env.DB_HOST ?? "localhost"; +const DB_PORT = asNumber(process.env.DB_PORT, 5432); +const DB_NAME = process.env.DB_NAME ?? ""; +const DB_USER = process.env.DB_USER ?? ""; +const DB_PASSWORD = process.env.DB_PASSWORD ?? ""; + +const DB_LOGGING = asBoolean(process.env.DB_LOGGING, false); + +// Modo de sincronización usado por model-loader.ts +const DB_SYNC_MODE = + (process.env.DB_SYNC_MODE as DbSyncMode | undefined) ?? (isProd ? "none" : "alter"); + +// Opcional: timezone para Sequelize (según necesidades) +const APP_TIMEZONE = process.env.APP_TIMEZONE ?? "Europe/Madrid"; + +// Proxy (no usáis ahora, pero dejamos la variable por si se activa en el futuro) +const TRUST_PROXY = asNumber(process.env.TRUST_PROXY, 0); + export const ENV = { - HOST: process.env.HOST || process.env.HOSTNAME || "localhost", - PORT: process.env.PORT || "18888", - NODE_ENV: process.env.NODE_ENV || "development", -}; + NODE_ENV, + HOST, + PORT, + FRONTEND_URL, + DATABASE_URL, + DB_DIALECT, + DB_HOST, + DB_PORT, + DB_NAME, + DB_USER, + DB_PASSWORD, + DB_LOGGING, + DB_SYNC_MODE, + APP_TIMEZONE, + TRUST_PROXY, +} as const; + +export const Flags = { + isProd, + isDev, + isTest: NODE_ENV === "test", +} as const; diff --git a/apps/server/src/health.ts b/apps/server/src/health.ts new file mode 100644 index 00000000..859f4412 --- /dev/null +++ b/apps/server/src/health.ts @@ -0,0 +1,35 @@ +import { Application, Request, Response } from "express"; +import { DateTime } from "luxon"; +/** + +Registra endpoints de liveness/readiness. + +/__health: siempre 200 mientras el proceso esté vivo. + +/__ready : 200 si ready=true, 503 en caso contrario. +*/ +export function registerHealthRoutes(app: Application, getStatus: () => { ready: boolean }): void { + // Liveness probe: indica que el proceso responde + app.get("/__health", (_req: Request, res: Response) => { + // Información mínima y no sensible + res.status(200).json({ + status: "ok", + time: DateTime.now().toISO(), + }); + }); + + // Readiness probe: indica que el servidor está listo para tráfico + app.get("/__ready", (_req: Request, res: Response) => { + const { ready } = getStatus(); + if (ready) { + return res.status(200).json({ + status: "ready", + time: DateTime.now().toISO(), + }); + } + return res.status(503).json({ + status: "not_ready", + time: DateTime.now().toISO(), + }); + }); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f30048c6..d77326ea 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -5,6 +5,7 @@ import os from "node:os"; import { createApp } from "./app"; import { ENV } from "./config"; import { tryConnectToDatabase } from "./config/database"; +import { registerHealthRoutes } from "./health"; import { listRoutes } from "./lib"; import { initModules } from "./lib/modules"; import { registerModules } from "./register-modules"; @@ -21,48 +22,87 @@ export const currentState = { connections: {} as Record, }; -// Manejo de cierre forzado del servidor (graceful shutdown) +// ───────────────────────────────────────────────────────────────────────────── +// 🔒 Bandera de apagado y promesa para idempotencia +let isShuttingDown = false; +let shutdownPromise: Promise | null = null; +// 🔒 Bandera de readiness (se actualiza durante arranque y apagado) +let isReady = false; + +// ───────────────────────────────────────────────────────────────────────────── +// Manejo de cierre forzado del servidor (graceful shutdown + sockets) const serverStop = (server: http.Server) => { const forceTimeout = 30000; - return new Promise((resolve, reject) => { + // Devolvemos siempre la misma promesa para evitar dobles cierres + if (shutdownPromise) return shutdownPromise; + + shutdownPromise = new Promise((resolve, reject) => { logger.warn("⚡️ Shutting down server"); - setTimeout(() => { - logger.error("Could not close connections in time, forcefully shutting down"); - resolve(); - }, forceTimeout).unref(); + // Tras el timeout, destruimos sockets vivos y resolvemos + const killer = setTimeout(() => { + try { + const sockets = Object.values(currentState.connections) as any[]; + let destroyed = 0; + for (const s of sockets) { + try { + // @ts-ignore - algunos runtimes exponen destroy() / end() + if (s.destroy && !s.destroyed) { + s.destroy(); + destroyed++; + } else if (s.end) { + s.end(); + } + } catch { + /* noop */ + } + } + logger.error(`Force timeout reached. Destroyed ${destroyed} remaining connection(s).`); + } finally { + resolve(); + } + }, forceTimeout); + // Evita mantener el event-loop vivo por el timeout + killer.unref(); + // Intentamos cerrar el servidor de forma limpia server.close((err) => { if (err) { + logger.error("Error while closing server", { err, label: "serverStop.close" }); return reject(err); } logger.info("Closed out remaining connections."); logger.info("❎ Bye!"); + clearTimeout(killer); resolve(); }); }); + + return shutdownPromise; }; +// ───────────────────────────────────────────────────────────────────────────── // Manejo de errores al iniciar el servidor const serverError = (error: NodeJS.ErrnoException) => { - logger.error(`⛔️ Server wasn't able to start properly.`, { + logger.error("⛔️ Server wasn't able to start properly.", { label: "serverError0", error, + host: ENV.HOST, + port: ENV.PORT, }); if (error.code === "EADDRINUSE") { - logger.error(error.message, { error, label: "serverError1" }); - //logger.error(`The port ${error.port} is already used by another application.`); + logger.error(`Port ${ENV.PORT} already in use`, { error, label: "serverError1" }); } else { logger.error(error.message, { error, label: "serverError2" }); } - // Dependiendo de la criticidad, podrías forzar el proceso a salir + // Fallo crítico de arranque → salida con código 1 process.exit(1); }; -// Almacena en "connections" cada nueva conexión (descomentar si se quiere seguimiento) +// Almacena en "connections" cada nueva conexión const serverConnection = (conn: any) => { const key = `${conn.remoteAddress}:${conn.remotePort}`; currentState.connections[key] = conn; @@ -72,53 +112,95 @@ const serverConnection = (conn: any) => { }); }; -//const sequelizeConn = createSequelizeAdapter(); -//const firebirdConn = createFirebirdAdapter(); - // Cargar paquetes de la aplicación: customers, invoices, routes... registerModules(); const app = createApp(); -// Crea el servidor HTTP -const server = http - .createServer(app) - .once("listening", () => - process.on("SIGINT", async () => { - // Por ejemplo, podrías desconectar la base de datos aquí: - // firebirdConn.disconnect(); - // O forzar desconexión en adapters - // sequelizeConn.close(); +// ➕ Rutas de salud disponibles desde el inicio del proceso +registerHealthRoutes(app, () => ({ ready: isReady })); - await serverStop(server); - }) - ) +// Crea el servidor HTTP +const server = http.createServer(app); + +// ⚙️ Ajustes explícitos de timeouts HTTP (evita cuelgues en keep-alive) +server.keepAliveTimeout = 70000; // el socket puede quedar vivo 70s +server.headersTimeout = 75000; // debe ser > keepAliveTimeout + +// ───────────────────────────────────────────────────────────────────────────── +// 🛎️ Señales del SO (registradas antes de listen) + apagado controlado +// En los manejadores de señales/errores, marcar no-ready al iniciar shutdown +const handleSignal = (signal: NodeJS.Signals) => async () => { + if (isShuttingDown) { + logger.warn(`Shutdown already in progress. Ignoring signal ${signal}.`); + return; + } + isShuttingDown = true; + // ⬇️ el servidor deja de estar listo inmediatamente + isReady = false; + + logger.warn(`Received ${signal}. Starting graceful shutdown...`); + try { + await serverStop(server); + process.exit(0); + } catch (err) { + logger.error("Error during shutdown, forcing exit(1)", { err, label: "handleSignal" }); + process.exit(1); + } +}; + +process.on("SIGINT", handleSignal("SIGINT")); +process.on("SIGTERM", handleSignal("SIGTERM")); + +// ───────────────────────────────────────────────────────────────────────────── +// Eventos del servidor +server + .once("listening", () => { + logger.info(`HTTP server is listening on port ${currentState.port}`); + }) .on("close", () => logger.info(`Shutdown at: ${DateTime.now().toLocaleString(DateTime.DATETIME_FULL)}`) ) .on("connection", serverConnection) .on("error", serverError); -// Ejemplo de adapters de base de datos (descoméntalos si los necesitas) -// const sequelizeConn = createSequelizeAdapter(); -// const firebirdConn = createFirebirdAdapter(); - -// Manejo de promesas no capturadas -process.on("unhandledRejection", (reason: any, promise: Promise) => { - const error = `❌ Unhandled rejection at:", ${promise}, "reason:", ${reason}`; - logger.error(error); - // Dependiendo de la aplicación, podrías desear una salida total o un cierre controlado - process.exit(1); +// ───────────────────────────────────────────────────────────────────────────── +// Manejo de promesas no capturadas: apagado controlado +// ... unhandledRejection/uncaughtException: también set isReady = false; +process.on("unhandledRejection", async (reason: any, promise: Promise) => { + logger.error("❌ Unhandled rejection", { + label: "unhandledRejection", + reason, + promise: String(promise), + }); + if (!isShuttingDown) { + isShuttingDown = true; + isReady = false; + try { + await serverStop(server); + } finally { + process.exit(1); + } + } }); -// Manejo de excepciones no controladas -process.on("uncaughtException", (error: Error) => { - // firebirdConn.disconnect(); - logger.error(`❌ Uncaught exception: ${error.message}`); - logger.error(error.stack); - // process.exit(1); +process.on("uncaughtException", async (error: Error) => { + logger.error(`❌ Uncaught exception: ${error.message}`, { + stack: error.stack, + label: "uncaughtException", + }); + if (!isShuttingDown) { + isShuttingDown = true; + isReady = false; + try { + await serverStop(server); + } finally { + process.exit(1); + } + } }); +// ───────────────────────────────────────────────────────────────────────────── // Arranca el servidor si la conexión a la base de datos va bien (async () => { try { @@ -129,12 +211,18 @@ process.on("uncaughtException", (error: Error) => { logger.info(`Process PID: ${process.pid}`); const database = await tryConnectToDatabase(); + // Lógica de inicialización de DB, si procede: // initStructure(sequelizeConn.connection); // insertUsers(); await initModules({ app, database, baseRoutePath: API_BASE_PATH, logger }); + // ✅ El servidor ya está listo para recibir tráfico + isReady = true; + logger.info("✅ Server is READY (readiness=true)"); + logger.info(`startup_duration_ms=${DateTime.now().diff(currentState.launchedAt).toMillis()}`); + server.listen(currentState.port, () => { server.emit("listening"); @@ -153,6 +241,9 @@ process.on("uncaughtException", (error: Error) => { } } + // URLs de acceso útiles + logger.info(`⚡️ Server accessible at: http://localhost:${currentState.port}`); + logger.info(`⚡️ Server accessible at: http://${currentState.host}:${currentState.port}`); for (const address of addresses) { logger.info(`⚡️ Server accessible at: http://${address}:${currentState.port}`); } @@ -165,9 +256,13 @@ process.on("uncaughtException", (error: Error) => { logger.info(`Server path: ${currentState.appPath}`); logger.info(`Server environment: ${currentState.environment}`); logger.info("To shut down your server, press + C at any time"); - logger.info(JSON.stringify(listRoutes(app._router, API_BASE_PATH), null, 3)); + + // En entornos de desarrollo puede ser muy verboso + logger.info(JSON.stringify(listRoutes((app as any)._router, API_BASE_PATH), null, 3)); }); } catch (error) { + // Arranque fallido → readiness sigue false + isReady = false; serverError(error as NodeJS.ErrnoException); } })(); diff --git a/apps/server/src/lib/logger/index.ts b/apps/server/src/lib/logger/index.ts index cda30be6..ca6bc5b2 100644 --- a/apps/server/src/lib/logger/index.ts +++ b/apps/server/src/lib/logger/index.ts @@ -1,4 +1,4 @@ -import { ILogger } from "@erp/core"; +import { ILogger } from "@erp/core/api"; import { ConsoleLogger } from "./console-logger"; // Aquí podrías cambiar por SentryLogger en el futuro diff --git a/apps/server/src/lib/modules/model-loader.ts b/apps/server/src/lib/modules/model-loader.ts index f6297e02..fbf355a4 100644 --- a/apps/server/src/lib/modules/model-loader.ts +++ b/apps/server/src/lib/modules/model-loader.ts @@ -1,55 +1,161 @@ import { ModuleParams } from "@erp/core/api"; +import { ENV } from "../../config"; import { logger } from "../logger"; -const allModelInitializers: any[] = []; -const registeredModels: { [key: string]: any } = {}; - /** - * 🔹 Registra todos los modelos en Sequelize - */ -export const registerModels = (models: any[], params?: ModuleParams) => { - allModelInitializers.push(...models); + Tipos mínimos para evitar acoplarse a una versión concreta de Sequelize +*/ +type SequelizeLike = { + sync: (options?: any) => Promise; + models: Record; }; +type ModelStatic = { + name: string; + // Algunas implementaciones añaden associate(sequelize) para definir relaciones + associate?: (sequelize: SequelizeLike) => void; +}; + +export type ModelInitializer = (sequelize: SequelizeLike) => ModelStatic; + +/** + Estructuras internas (no exportadas) con trazabilidad de módulo. +*/ +type InitializerEntry = { init: ModelInitializer; moduleName: string }; + +const allModelInitializers: InitializerEntry[] = []; +const registeredModels: Map = new Map(); + +let modelsInitialized = false; + +/** + 🔹 Registra inicializadores de modelos de un módulo + models: lista de funciones que definen modelos (sequelize) => ModelStatic + ctx.moduleName: para trazabilidad y detección de colisiones +*/ +export const registerModels = ( + models: ModelInitializer[], + _params?: ModuleParams, + ctx?: { moduleName: string } +) => { + const moduleName = ctx?.moduleName ?? "unknown"; + if (!Array.isArray(models) || models.length === 0) { + logger.warn(`No models provided by module "${moduleName}"`, { label: "registerModels" }); + return; + } + for (const init of models) { + // Guardamos con el módulo que los aporta (para logs/errores) + allModelInitializers.push({ init, moduleName }); + } + logger.info(`📦 ${models.length} model initializer(s) enqueued from "${moduleName}"`, { + label: "registerModels", + }); +}; + +/** + 🔹 Inicializa todos los modelos registrados y configura asociaciones. + Detecta colisiones por nombre de modelo entre módulos. + Controla el modo de sincronización por ENV. +*/ export const initModels = async (params: ModuleParams) => { + if (modelsInitialized) { + logger.warn("Models already initialized. Skipping initModels()", { label: "initModels" }); + return; + } + logger.info("Init models...", { label: "initModels" }); - const { database } = params; + const { database } = params as { database: SequelizeLike }; if (!database) { const error = new Error("❌ Database not found."); - logger.error(error.message, { - label: "initModels", - }); + logger.error(error.message, { label: "initModels" }); throw error; } - // Inicializar modelos - for (const initializer of allModelInitializers) { - const model = initializer(database); - registeredModels[model.name] = model; - logger.info(`🔸 Model "${model.name}" registered (sequelize)`, { label: "registerModel" }); - } + // 1) Definir modelos (y detectar colisiones) + for (const { init, moduleName } of allModelInitializers) { + try { + const model = init(database); + if (!model || typeof model.name !== "string" || !model.name) { + throw new Error(`Invalid model initializer: missing or empty "name"`); + } - // Configurar asociaciones - for (const model of Object.values(registeredModels)) { - if (typeof model.associate === "function") { - model.associate(database); + if (registeredModels.has(model.name)) { + const existing = registeredModels.get(model.name)!; + throw new Error( + `Model name collision: "${model.name}" from module "${moduleName}" conflicts with existing model from "${existing.moduleName}"` + ); + } + + registeredModels.set(model.name, { model, moduleName }); + logger.info(`🔸 Model "${model.name}" registered (sequelize)`, { + label: "registerModel", + module: moduleName, + }); + } catch (err: any) { + // Agregamos contexto del módulo que aportó el initializer + logger.error(`⛔️ Failed to define model (module="${moduleName}") : ${err?.message ?? err}`, { + label: "initModels", + stack: err?.stack, + }); + throw err; } } + // 2) Configurar asociaciones + for (const { model, moduleName } of registeredModels.values()) { + if (typeof model.associate === "function") { + try { + model.associate(database); + } catch (err: any) { + logger.error( + `⛔️ Failed to associate model "${model.name}" (module="${moduleName}") : ${err?.message ?? err}`, + { label: "initModels", stack: err?.stack } + ); + throw err; + } + } + } + + // 3) Sincronizar base de datos (según modo) + const nodeEnv = ENV.NODE_ENV ?? process.env.NODE_ENV ?? "development"; + const syncModeEnv = (ENV as any).DB_SYNC_MODE ?? process.env.DB_SYNC_MODE; // "none" | "alter" | "force" + const defaultMode = nodeEnv === "production" ? "none" : "alter"; + const mode = (syncModeEnv ?? defaultMode) as "none" | "alter" | "force"; + try { - // Sincronizamos DB en modo desarrollo - if (process.env.NODE_ENV !== "production") { + if (mode === "none") { + logger.info("✔️ Database sync skipped (mode=none)", { label: "initModels" }); + } else if (mode === "alter") { await database.sync({ force: false, alter: true }); - logger.info(`✔️${" "}Database synchronized successfully.`, { label: "initModels" }); + logger.info("✔️ Database synchronized successfully (mode=alter).", { label: "initModels" }); + } else if (mode === "force") { + await database.sync({ force: true, alter: false }); + logger.warn("⚠️ Database synchronized with FORCE (mode=force).", { label: "initModels" }); } else { - logger.warn("⚠️ Running in production mode - Skipping database sync.", { - label: "initModels", - }); + logger.warn(`⚠️ Unknown DB sync mode "${String(mode)}" → skipping`, { label: "initModels" }); } } catch (err) { const error = err as Error; logger.error("❌ Error synchronizing database:", { error, label: "initModels" }); throw error; + } finally { + modelsInitialized = true; } }; + +/** + 🔹 Utilidades opcionales (DX/tests) +*/ +export const getRegisteredModels = () => + Array.from(registeredModels.entries()).map(([name, { moduleName }]) => ({ + name, + moduleName, + })); + +export const resetModelsRegistry = () => { + allModelInitializers.length = 0; + registeredModels.clear(); + modelsInitialized = false; + logger.info("Model registry has been reset.", { label: "initModels" }); +}; diff --git a/apps/server/src/lib/modules/module-loader.ts b/apps/server/src/lib/modules/module-loader.ts index ed1ed73c..232c83ca 100644 --- a/apps/server/src/lib/modules/module-loader.ts +++ b/apps/server/src/lib/modules/module-loader.ts @@ -5,50 +5,121 @@ import { registerService } from "./service-registry"; const registeredModules: Map = new Map(); const initializedModules = new Set(); +const visiting = new Set(); // para detección de ciclos +/** + Registra un módulo del servidor en el registry. + Lanza error si el nombre ya existe. +*/ export function registerModule(pkg: IModuleServer) { - console.log(pkg.name); + if (!pkg?.name) { + throw new Error('❌ Invalid module: missing "name" property'); + } if (registeredModules.has(pkg.name)) { - throw new Error(`❌ Paquete "${pkg.name}" ya registrado.`); + throw new Error(`❌ Module "${pkg.name}" already registered`); } registeredModules.set(pkg.name, pkg); + logger.info(`📦 Module enqueued: "${pkg.name}"`, { label: "moduleRegistry" }); } +/** + Inicializa todos los módulos registrados (resolviendo dependencias), + y al final inicializa los modelos (Sequelize) en bloque. +*/ export async function initModules(params: ModuleParams) { - registeredModules.forEach((_, name) => { - loadModule(name, params); + for (const name of registeredModules.keys()) { + await loadModule(name, params, []); // secuencial para logs deterministas + } + await withPhase("global", "initModels", async () => { + await initModels(params); }); - await initModels(params); } -const loadModule = (name: string, params: ModuleParams) => { +/** + Carga recursivamente un módulo y sus dependencias con detección de ciclos. +*/ +async function loadModule(name: string, params: ModuleParams, stack: string[]) { if (initializedModules.has(name)) return; - const pkg = registeredModules.get(name); - if (!pkg) throw new Error(`❌ Paquete "${name}" no encontrado.`); - - // Resolver dependencias primero - const deps = pkg.dependencies || []; - deps.forEach((dep) => loadModule(dep, params)); - - // Inicializar el module - pkg.init(params); - - const pkgApi = pkg.registerDependencies?.(params); - - // Registrar modelos de Sequelize, si los expone - if (pkgApi?.models) { - registerModels(pkgApi.models, params); + if (visiting.has(name)) { + // Ciclo detectado: construir traza legible + const cyclePath = [...stack, name].join(" -> "); + throw new Error(`❌ Cyclic dependency detected: ${cyclePath}`); } - // Registrar sus servicios, si los expone - if (pkgApi?.services) { - const services = pkgApi.services; - if (services && typeof services === "object") { - registerService(pkg.name, services); - } + const pkg = registeredModules.get(name); + if (!pkg) { + throw new Error( + `❌ Module "${name}" not found (required by: ${stack[stack.length - 1] ?? "root"})` + ); + } + + visiting.add(name); + stack.push(name); + + // 1) Resolver dependencias primero (en orden) + const deps = pkg.dependencies || []; + for (const dep of deps) { + await loadModule(dep, params, stack.slice()); + } + + // 2) Inicializar el módulo (permite async) + await withPhase(name, "init", async () => { + await Promise.resolve(pkg.init(params)); + }); + + // 3) Registrar dependencias que expone (permite async) + const pkgApi = await withPhase(name, "registerDependencies", async () => { + return await Promise.resolve(pkg.registerDependencies?.(params)); + }); + + // 4) Registrar modelos de Sequelize, si existen + if (pkgApi?.models) { + await withPhase(name, "registerModels", async () => { + await Promise.resolve(registerModels(pkgApi.models, params, { moduleName: pkg.name })); + }); + } + + // 5) Registrar servicios, si existen + if (pkgApi?.services && typeof pkgApi.services === "object") { + await withPhase(name, "registerServices", async () => { + await Promise.resolve(registerService(pkg.name, pkgApi.services)); + }); } initializedModules.add(name); - logger.info(`✅ Module "${name}" registered`, { label: "loadModule" }); -}; + visiting.delete(name); + stack.pop(); + + logger.info(`✅ Module "${name}" registered`, { label: "moduleRegistry" }); +} + +/** + Helper para anotar fase y módulo en errores y logs. +*/ +async function withPhase( + moduleName: string, + phase: "init" | "registerDependencies" | "registerModels" | "registerServices" | "initModels", + fn: () => Promise | T +): Promise { + try { + return await fn(); + } catch (error: any) { + // Log enriquecido con contexto + logger.error( + `⛔️ Module "${moduleName}" failed at phase="${phase}": ${error?.message ?? error}`, + { + label: "moduleRegistry", + module: moduleName, + phase, + stack: error?.stack, + } + ); + // Re-lanzamos con contexto preservado + const err = new Error( + `[module=${moduleName}] phase=${phase} failed: ${error?.message ?? "Unknown error"}` + ); + (err as any).cause = error; + throw err; + } +} diff --git a/modules/auth/src/api/index.ts b/modules/auth/src/api/index.ts index 0c844552..0bc59d45 100644 --- a/modules/auth/src/api/index.ts +++ b/modules/auth/src/api/index.ts @@ -4,13 +4,13 @@ export const authAPIModule: IModuleServer = { name: "auth", version: "1.0.0", dependencies: [], - init(params: ModuleParams) { + async init(params: ModuleParams) { // const contacts = getService("contacts"); const { logger } = params; //invoicesRouter(params); logger.info("🚀 Auth module initialized", { label: "auth" }); }, - registerDependencies(params: ModuleParams) { + async registerDependencies(params: ModuleParams) { const { database, logger } = params; logger.info("🚀 Auth module dependencies registered", { label: "auth" }); return { diff --git a/modules/core/src/api/modules/module-server.interface.ts b/modules/core/src/api/modules/module-server.interface.ts index 32fe2d95..25fa867e 100644 --- a/modules/core/src/api/modules/module-server.interface.ts +++ b/modules/core/src/api/modules/module-server.interface.ts @@ -4,6 +4,6 @@ import { ModuleMetadata } from "../../common"; import { ModuleDependencies, ModuleParams } from "./types"; export interface IModuleServer extends ModuleMetadata { - init(params: ModuleParams): void; - registerDependencies?(params: ModuleParams): ModuleDependencies; + init(params: ModuleParams): Promise; + registerDependencies?(params: ModuleParams): Promise; } diff --git a/modules/customer-invoices/src/api/index.ts b/modules/customer-invoices/src/api/index.ts index 7c8fd8f8..2240642c 100644 --- a/modules/customer-invoices/src/api/index.ts +++ b/modules/customer-invoices/src/api/index.ts @@ -6,13 +6,13 @@ export const customerInvoicesAPIModule: IModuleServer = { version: "1.0.0", dependencies: [], - init(params: ModuleParams) { + async init(params: ModuleParams) { // const contacts = getService("contacts"); const { logger } = params; customerInvoicesRouter(params); logger.info("🚀 CustomerInvoices module initialized", { label: "customer-invoices" }); }, - registerDependencies(params) { + async registerDependencies(params) { const { database, logger } = params; logger.info("🚀 CustomerInvoices module dependencies registered", { label: "customer-invoices", diff --git a/modules/customers/src/api/index.ts b/modules/customers/src/api/index.ts index 629b2ef0..4d95288d 100644 --- a/modules/customers/src/api/index.ts +++ b/modules/customers/src/api/index.ts @@ -6,13 +6,13 @@ export const customersAPIModule: IModuleServer = { version: "1.0.0", dependencies: [], - init(params: ModuleParams) { + async init(params: ModuleParams) { // const contacts = getService("contacts"); const { logger } = params; customersRouter(params); logger.info("🚀 Customers module initialized", { label: "customers" }); }, - registerDependencies(params) { + async registerDependencies(params) { const { database, logger } = params; logger.info("🚀 Customers module dependencies registered", { label: "customers",