@kaikybrofc/omnizap-system 2.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/.env.example +534 -0
  2. package/LICENSE +21 -0
  3. package/README.md +431 -0
  4. package/RELEASE-v2.1.2.md +83 -0
  5. package/app/config/adminIdentity.js +87 -0
  6. package/app/config/baileysConfig.js +693 -0
  7. package/app/config/groupUtils.js +388 -0
  8. package/app/connection/socketController.js +992 -0
  9. package/app/controllers/messageController.js +354 -0
  10. package/app/modules/adminModule/groupCommandHandlers.js +1294 -0
  11. package/app/modules/adminModule/groupEventHandlers.js +355 -0
  12. package/app/modules/aiModule/catCommand.js +1006 -0
  13. package/app/modules/broadcastModule/noticeCommand.js +416 -0
  14. package/app/modules/gameModule/diceCommand.js +67 -0
  15. package/app/modules/menuModule/common.js +311 -0
  16. package/app/modules/menuModule/menus.js +59 -0
  17. package/app/modules/playModule/playCommand.js +1615 -0
  18. package/app/modules/quoteModule/quoteCommand.js +851 -0
  19. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +786 -0
  20. package/app/modules/rpgPokemonModule/rpgBattleService.js +2082 -0
  21. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +760 -0
  22. package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
  23. package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +172 -0
  24. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
  25. package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
  26. package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
  27. package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
  28. package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1859 -0
  29. package/app/modules/rpgPokemonModule/rpgPokemonService.js +6738 -0
  30. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
  31. package/app/modules/statsModule/globalRankingCommand.js +65 -0
  32. package/app/modules/statsModule/noMessageCommand.js +288 -0
  33. package/app/modules/statsModule/rankingCommand.js +60 -0
  34. package/app/modules/statsModule/rankingCommon.js +889 -0
  35. package/app/modules/stickerModule/addStickerMetadata.js +239 -0
  36. package/app/modules/stickerModule/convertToWebp.js +390 -0
  37. package/app/modules/stickerModule/stickerCommand.js +454 -0
  38. package/app/modules/stickerModule/stickerConvertCommand.js +156 -0
  39. package/app/modules/stickerModule/stickerTextCommand.js +657 -0
  40. package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
  41. package/app/modules/stickerPackModule/autoPackCollectorService.js +284 -0
  42. package/app/modules/stickerPackModule/semanticReclassificationEngine.js +466 -0
  43. package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +88 -0
  44. package/app/modules/stickerPackModule/semanticThemeClusterService.js +571 -0
  45. package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +449 -0
  46. package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
  47. package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +180 -0
  48. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +4078 -0
  49. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +598 -0
  50. package/app/modules/stickerPackModule/stickerClassificationService.js +588 -0
  51. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +102 -0
  52. package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +7506 -0
  53. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1095 -0
  54. package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +108 -0
  55. package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
  56. package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +110 -0
  57. package/app/modules/stickerPackModule/stickerPackItemRepository.js +440 -0
  58. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +337 -0
  59. package/app/modules/stickerPackModule/stickerPackMessageService.js +296 -0
  60. package/app/modules/stickerPackModule/stickerPackRepository.js +442 -0
  61. package/app/modules/stickerPackModule/stickerPackService.js +788 -0
  62. package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +51 -0
  63. package/app/modules/stickerPackModule/stickerPackUtils.js +97 -0
  64. package/app/modules/stickerPackModule/stickerStorageService.js +507 -0
  65. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +233 -0
  66. package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +205 -0
  67. package/app/modules/systemMetricsModule/pingCommand.js +421 -0
  68. package/app/modules/tiktokModule/tiktokCommand.js +798 -0
  69. package/app/modules/userModule/userCommand.js +1217 -0
  70. package/app/modules/waifuPicsModule/waifuPicsCommand.js +177 -0
  71. package/app/observability/metrics.js +734 -0
  72. package/app/services/captchaService.js +492 -0
  73. package/app/services/dbWriteQueue.js +572 -0
  74. package/app/services/groupMetadataService.js +279 -0
  75. package/app/services/lidMapService.js +663 -0
  76. package/app/services/messagePersistenceService.js +56 -0
  77. package/app/services/newsBroadcastService.js +351 -0
  78. package/app/services/pokeApiService.js +398 -0
  79. package/app/services/queueUtils.js +57 -0
  80. package/app/services/socketState.js +7 -0
  81. package/app/store/aiPromptStore.js +38 -0
  82. package/app/store/groupConfigStore.js +58 -0
  83. package/app/store/premiumUserStore.js +36 -0
  84. package/app/utils/antiLink/antiLinkModule.js +804 -0
  85. package/app/utils/http/getImageBufferModule.js +18 -0
  86. package/app/utils/json/jsonSanitizer.js +113 -0
  87. package/app/utils/json/jsonSanitizer.test.js +40 -0
  88. package/app/utils/logger/loggerModule.js +262 -0
  89. package/app/utils/systemMetrics/systemMetricsModule.js +91 -0
  90. package/database/index.js +2052 -0
  91. package/database/init.js +516 -0
  92. package/database/migrations/20260203_0001_sticker_packs.sql +54 -0
  93. package/database/migrations/20260210_0003_rpg_pokemon.sql +58 -0
  94. package/database/migrations/20260210_0004_rpg_shiny_biome.sql +9 -0
  95. package/database/migrations/20260210_0005_rpg_missions.sql +14 -0
  96. package/database/migrations/20260210_0006_rpg_world_pokedex_traits.sql +27 -0
  97. package/database/migrations/20260210_0007_rpg_raid_pvp.sql +56 -0
  98. package/database/migrations/20260210_0008_rpg_social_system.sql +195 -0
  99. package/database/migrations/20260211_0009_rpg_social_xp.sql +36 -0
  100. package/database/migrations/20260222_0010_remove_message_xp.sql +2 -0
  101. package/database/migrations/20260226_0011_sticker_asset_classification.sql +17 -0
  102. package/database/migrations/20260226_0012_sticker_pack_engagement.sql +16 -0
  103. package/database/migrations/20260226_0013_sticker_marketplace_intelligence.sql +19 -0
  104. package/database/migrations/20260226_0014_sticker_pack_publish_flow.sql +30 -0
  105. package/database/migrations/20260226_0014_sticker_worker_queues.sql +42 -0
  106. package/database/migrations/20260226_0015_sticker_auto_pack_curation_integrity.sql +18 -0
  107. package/database/migrations/20260226_0016_sticker_web_google_auth_persistence.sql +34 -0
  108. package/database/migrations/20260226_0017_sticker_web_admin_ban.sql +22 -0
  109. package/database/migrations/20260226_0018_sticker_web_admin_moderator.sql +18 -0
  110. package/database/migrations/20260227_0019_sticker_classification_v2_signals.sql +12 -0
  111. package/database/migrations/20260227_0020_semantic_theme_clusters.sql +35 -0
  112. package/docker-compose.yml +103 -0
  113. package/ecosystem.prod.config.cjs +35 -0
  114. package/eslint.config.js +61 -0
  115. package/index.js +437 -0
  116. package/ml/clip_classifier/Dockerfile +16 -0
  117. package/ml/clip_classifier/README.md +120 -0
  118. package/ml/clip_classifier/adaptive_scoring.py +40 -0
  119. package/ml/clip_classifier/classifier.py +654 -0
  120. package/ml/clip_classifier/embedding_store.py +481 -0
  121. package/ml/clip_classifier/env_loader.py +15 -0
  122. package/ml/clip_classifier/llm_label_expander.py +144 -0
  123. package/ml/clip_classifier/main.py +213 -0
  124. package/ml/clip_classifier/requirements.txt +10 -0
  125. package/ml/clip_classifier/similarity_engine.py +74 -0
  126. package/observability/alert-rules.yml +60 -0
  127. package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
  128. package/observability/grafana/dashboards/omnizap-overview.json +170 -0
  129. package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
  130. package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
  131. package/observability/loki-config.yml +38 -0
  132. package/observability/mysql-exporter.cnf +5 -0
  133. package/observability/mysql-setup.sql +46 -0
  134. package/observability/prometheus.yml +32 -0
  135. package/observability/promtail-config.yml +84 -0
  136. package/package.json +109 -0
  137. package/public/api-docs/index.html +144 -0
  138. package/public/css/github-project-panel.css +297 -0
  139. package/public/css/stickers-admin.css +1272 -0
  140. package/public/css/styles.css +671 -0
  141. package/public/index.html +1311 -0
  142. package/public/js/apps/apiDocsApp.js +310 -0
  143. package/public/js/apps/createPackApp.js +2069 -0
  144. package/public/js/apps/homeApp.js +396 -0
  145. package/public/js/apps/stickersAdminApp.js +1744 -0
  146. package/public/js/apps/stickersApp.js +4830 -0
  147. package/public/js/catalog.js +1019 -0
  148. package/public/js/github-panel/components/CommitList.js +34 -0
  149. package/public/js/github-panel/components/ErrorState.js +16 -0
  150. package/public/js/github-panel/components/GithubProjectPanel.js +106 -0
  151. package/public/js/github-panel/components/ReleaseList.js +38 -0
  152. package/public/js/github-panel/components/SkeletonPanel.js +22 -0
  153. package/public/js/github-panel/components/StatCard.js +15 -0
  154. package/public/js/github-panel/index.js +15 -0
  155. package/public/js/github-panel/useGithubRepoData.js +154 -0
  156. package/public/js/github-panel/vendor/react.js +11 -0
  157. package/public/js/runtime/react-runtime.js +19 -0
  158. package/public/licenca/index.html +106 -0
  159. package/public/stickers/admin/index.html +23 -0
  160. package/public/stickers/create/index.html +47 -0
  161. package/public/stickers/index.html +48 -0
  162. package/public/termos-de-uso/index.html +125 -0
  163. package/scripts/cache-bust.mjs +107 -0
  164. package/scripts/deploy.sh +458 -0
  165. package/scripts/github-deploy-notify.mjs +174 -0
  166. package/scripts/release.sh +129 -0
@@ -0,0 +1,2052 @@
1
+ /* eslint-disable no-unused-vars */
2
+ /* eslint-disable no-useless-escape */
3
+
4
+ /**
5
+ * Módulo de acesso ao MySQL com:
6
+ * - Pool de conexões (mysql2/promise)
7
+ * - Monitoramento de queries (latência, slow queries, erros, top queries)
8
+ * - Log estruturado em arquivo com rotação
9
+ * - Integração opcional com métricas (Prometheus / observability)
10
+ *
11
+ * Objetivo:
12
+ * centralizar todas as operações de banco e entregar:
13
+ * ✅ execução segura (sanitize params)
14
+ * ✅ diagnóstico (stats + logs)
15
+ * ✅ métricas (quando habilitadas)
16
+ */
17
+
18
+ import 'dotenv/config';
19
+ import mysql from 'mysql2/promise';
20
+ import fs from 'node:fs';
21
+ import { once } from 'node:events';
22
+ import path from 'node:path';
23
+ import { promises as fsPromises } from 'node:fs';
24
+ import logger from '../app/utils/logger/loggerModule.js';
25
+ import { isMetricsEnabled, recordDbQuery, recordDbWrite, recordError, setDbInFlight } from '../app/observability/metrics.js';
26
+
27
+ const { NODE_ENV } = process.env;
28
+ const { DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_POOL_LIMIT = 10 } = process.env;
29
+
30
+ /**
31
+ * Lista de variáveis de ambiente obrigatórias para inicializar o banco.
32
+ * Caso faltem, o processo encerra para evitar rodar o app em estado inválido.
33
+ * @type {string[]}
34
+ */
35
+ const requiredEnvVars = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
36
+ const missingEnvVars = requiredEnvVars.filter((varName) => !process.env[varName]);
37
+
38
+ if (missingEnvVars.length > 0) {
39
+ logger.error(`Variáveis de ambiente de banco de dados necessárias não encontradas: ${missingEnvVars.join(', ')}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ /**
44
+ * Ambiente atual (production / development etc).
45
+ * Usado para definir defaults e nome do banco com sufixo _dev/_prod.
46
+ * @type {string}
47
+ */
48
+ const environment = NODE_ENV || 'development';
49
+
50
+ /**
51
+ * Resolve o nome do banco baseado no ambiente.
52
+ * - Em produção, adiciona sufixo `_prod`
53
+ * - Em desenvolvimento, adiciona sufixo `_dev`
54
+ * - Se já tiver _dev ou _prod, mantém como está
55
+ *
56
+ * @param {string} baseName Nome base do banco (DB_NAME)
57
+ * @param {string} env Ambiente (production/development)
58
+ * @returns {string} Nome final do banco
59
+ */
60
+ const resolveDbName = (baseName, env) => {
61
+ const suffix = env === 'production' ? 'prod' : 'dev';
62
+ if (baseName.endsWith('_dev') || baseName.endsWith('_prod')) {
63
+ return baseName;
64
+ }
65
+ return `${baseName}_${suffix}`;
66
+ };
67
+
68
+ const dbName = resolveDbName(DB_NAME, environment);
69
+
70
+ /**
71
+ * Configuração do banco baseada nas variáveis de ambiente.
72
+ * Esse objeto é exportado para ser utilizado por outros módulos (ex: init/migrations).
73
+ *
74
+ * @type {{host: string, user: string, password: string, database: string, poolLimit: number}}
75
+ */
76
+ export const dbConfig = {
77
+ host: DB_HOST,
78
+ user: DB_USER,
79
+ password: DB_PASSWORD,
80
+ database: dbName,
81
+ poolLimit: Number(DB_POOL_LIMIT),
82
+ };
83
+
84
+ logger.info(`Configuração de banco de dados carregada para o ambiente: ${environment}`);
85
+
86
+ /**
87
+ * Mapa de tabelas suportadas no sistema (allow-list).
88
+ * Ajuda a reduzir risco de SQL injection em funções que aceitam "tableName".
89
+ *
90
+ * @type {{MESSAGES: string, CHATS: string, GROUPS_METADATA: string, GROUP_CONFIGS: string, LID_MAP: string}}
91
+ */
92
+ export const TABLES = {
93
+ MESSAGES: 'messages',
94
+ CHATS: 'chats',
95
+ GROUPS_METADATA: 'groups_metadata',
96
+ GROUP_CONFIGS: 'group_configs',
97
+ LID_MAP: 'lid_map',
98
+ STICKER_PACK: 'sticker_pack',
99
+ STICKER_ASSET: 'sticker_asset',
100
+ STICKER_PACK_ITEM: 'sticker_pack_item',
101
+ STICKER_PACK_WEB_UPLOAD: 'sticker_pack_web_upload',
102
+ STICKER_ASSET_CLASSIFICATION: 'sticker_asset_classification',
103
+ SEMANTIC_THEME_CLUSTER: 'semantic_theme_clusters',
104
+ SEMANTIC_THEME_SUGGESTION_CACHE: 'semantic_theme_suggestion_cache',
105
+ STICKER_PACK_ENGAGEMENT: 'sticker_pack_engagement',
106
+ STICKER_PACK_INTERACTION_EVENT: 'sticker_pack_interaction_event',
107
+ STICKER_ASSET_REPROCESS_QUEUE: 'sticker_asset_reprocess_queue',
108
+ STICKER_WORKER_TASK_QUEUE: 'sticker_worker_task_queue',
109
+ STICKER_WEB_GOOGLE_USER: 'sticker_web_google_user',
110
+ STICKER_WEB_GOOGLE_SESSION: 'sticker_web_google_session',
111
+ STICKER_WEB_ADMIN_BAN: 'sticker_web_admin_ban',
112
+ STICKER_WEB_ADMIN_MODERATOR: 'sticker_web_admin_moderator',
113
+ RPG_PLAYER: 'rpg_player',
114
+ RPG_PLAYER_POKEMON: 'rpg_player_pokemon',
115
+ RPG_BATTLE_STATE: 'rpg_battle_state',
116
+ RPG_PLAYER_INVENTORY: 'rpg_player_inventory',
117
+ RPG_GROUP_BIOME: 'rpg_group_biome',
118
+ RPG_PLAYER_MISSION_PROGRESS: 'rpg_player_mission_progress',
119
+ RPG_PLAYER_POKEDEX: 'rpg_player_pokedex',
120
+ RPG_PLAYER_TRAVEL: 'rpg_player_travel',
121
+ RPG_RAID_STATE: 'rpg_raid_state',
122
+ RPG_RAID_PARTICIPANT: 'rpg_raid_participant',
123
+ RPG_PVP_CHALLENGE: 'rpg_pvp_challenge',
124
+ RPG_PVP_QUEUE: 'rpg_pvp_queue',
125
+ RPG_PVP_WEEKLY_STATS: 'rpg_pvp_weekly_stats',
126
+ RPG_SOCIAL_LINK: 'rpg_social_link',
127
+ RPG_TRADE_OFFER: 'rpg_trade_offer',
128
+ RPG_GROUP_COOP_WEEKLY: 'rpg_group_coop_weekly',
129
+ RPG_GROUP_COOP_MEMBER: 'rpg_group_coop_member',
130
+ RPG_GROUP_EVENT_WEEKLY: 'rpg_group_event_weekly',
131
+ RPG_GROUP_EVENT_MEMBER: 'rpg_group_event_member',
132
+ RPG_KARMA_PROFILE: 'rpg_karma_profile',
133
+ RPG_KARMA_VOTE_HISTORY: 'rpg_karma_vote_history',
134
+ RPG_GROUP_ACTIVITY_DAILY: 'rpg_group_activity_daily',
135
+ RPG_SOCIAL_XP_DAILY: 'rpg_social_xp_daily',
136
+ };
137
+
138
+ /**
139
+ * Pool de conexões com o MySQL.
140
+ * - waitForConnections: enfileira caso limite estoure
141
+ * - connectionLimit: máximo de conexões simultâneas
142
+ * - timezone 'Z': mantém timestamps em UTC
143
+ * - utf8mb4: suporta emojis e caracteres especiais
144
+ *
145
+ * @type {import('mysql2/promise').Pool}
146
+ */
147
+ export const pool = mysql.createPool({
148
+ host: dbConfig.host,
149
+ user: dbConfig.user,
150
+ password: dbConfig.password,
151
+ database: dbConfig.database,
152
+ waitForConnections: true,
153
+ connectionLimit: dbConfig.poolLimit,
154
+ queueLimit: 0,
155
+ timezone: 'Z',
156
+ charset: 'utf8mb4',
157
+ });
158
+
159
+ /**
160
+ * Converte strings de env para boolean de forma tolerante.
161
+ * Aceita: 1/0, true/false, yes/no, y/n, on/off.
162
+ *
163
+ * @param {unknown} value Valor bruto vindo do process.env
164
+ * @param {boolean} fallback Valor padrão se não for possível interpretar
165
+ * @returns {boolean}
166
+ */
167
+ const parseEnvBool = (value, fallback) => {
168
+ if (value === undefined || value === null || value === '') {
169
+ return fallback;
170
+ }
171
+ const normalized = String(value).trim().toLowerCase();
172
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
173
+ return true;
174
+ }
175
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
176
+ return false;
177
+ }
178
+ return fallback;
179
+ };
180
+
181
+ /**
182
+ * Converte strings de env para número com fallback.
183
+ *
184
+ * @param {unknown} value Valor bruto (env)
185
+ * @param {number} fallback Valor padrão se parse falhar
186
+ * @returns {number}
187
+ */
188
+ const parseEnvNumber = (value, fallback) => {
189
+ const parsed = Number(value);
190
+ return Number.isFinite(parsed) ? parsed : fallback;
191
+ };
192
+
193
+ /**
194
+ * Flag que indica se o subsistema de métricas está ativo.
195
+ * Quando ativo, o código registra métricas de:
196
+ * - duração
197
+ * - tipo da query
198
+ * - tabela
199
+ * - erro/slow
200
+ * - in-flight
201
+ *
202
+ * @type {boolean}
203
+ */
204
+ const METRICS_ACTIVE = isMetricsEnabled();
205
+
206
+ /**
207
+ * Keys permitidas no objeto options de executeQuery().
208
+ * Hoje só aceitamos traceId para correlacionar logs/requests.
209
+ * @type {Set<string>}
210
+ */
211
+ const EXECUTE_OPTIONS_KEYS = new Set(['traceId']);
212
+
213
+ /**
214
+ * Valida se um objeto recebido é um "options" válido.
215
+ * Isso é usado pra suportar assinatura antiga (options no 3º parâmetro).
216
+ *
217
+ * @param {unknown} value
218
+ * @returns {value is {traceId?: string}}
219
+ */
220
+ const isValidExecuteOptions = (value) => {
221
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
222
+ return false;
223
+ }
224
+ const keys = Object.keys(value);
225
+ if (!keys.length) {
226
+ return false;
227
+ }
228
+ return keys.every((key) => EXECUTE_OPTIONS_KEYS.has(key));
229
+ };
230
+
231
+ /**
232
+ * Defaults de monitor:
233
+ * - Em produção, default é desativado (evita overhead e risco de logs excessivos)
234
+ * - Em dev/staging, default é ativado (ajuda debug)
235
+ */
236
+ const DB_MONITOR_DEFAULT_ENABLED = environment !== 'production';
237
+
238
+ /**
239
+ * Configurações do monitor via env.
240
+ * - slowMs: a partir de quantos ms considerar slow query
241
+ * - logEveryQuery: loga todas as queries (cuidado em produção)
242
+ * - topN / sampleSize: ranking e amostra
243
+ * - slowExplain: executa EXPLAIN para SELECT lentos (cuidado: pode custar)
244
+ * - logPath/rotação
245
+ * - snapshotEveryMs: escreve snapshots periódicos do estado
246
+ */
247
+ const DB_MONITOR_ENABLED = parseEnvBool(process.env.DB_MONITOR_ENABLED, DB_MONITOR_DEFAULT_ENABLED);
248
+ const DB_SLOW_QUERY_MS = parseEnvNumber(process.env.DB_SLOW_QUERY_MS, 250);
249
+ const DB_LOG_EVERY_QUERY = parseEnvBool(process.env.DB_LOG_EVERY_QUERY, false);
250
+ const DB_STATS_TOP_N = Math.max(1, Math.floor(parseEnvNumber(process.env.DB_STATS_TOP_N, 10)));
251
+ const DB_STATS_SAMPLE_SIZE = Math.max(0, Math.floor(parseEnvNumber(process.env.DB_STATS_SAMPLE_SIZE, 2000)));
252
+ const DB_SLOW_EXPLAIN = parseEnvBool(process.env.DB_SLOW_EXPLAIN, false);
253
+ const rawMonitorLogPath = process.env.DB_MONITOR_LOG_PATH;
254
+ const DB_MONITOR_LOG_PATH = rawMonitorLogPath && rawMonitorLogPath.trim() !== '' ? path.resolve(rawMonitorLogPath) : path.resolve('logs', 'db-monitor.log');
255
+ const DB_MONITOR_LOG_ROTATE_MB = Math.max(0, parseEnvNumber(process.env.DB_MONITOR_LOG_ROTATE_MB, 20));
256
+ const DB_MONITOR_LOG_KEEP = Math.max(0, Math.floor(parseEnvNumber(process.env.DB_MONITOR_LOG_KEEP, 5)));
257
+ const DB_MONITOR_SNAPSHOT_EVERY_MS = Math.max(0, Math.floor(parseEnvNumber(process.env.DB_MONITOR_SNAPSHOT_EVERY_MS, 0)));
258
+
259
+ /**
260
+ * Ativa warning sobre assinatura antiga de executeQuery() quando options vierem no 3º parâmetro.
261
+ * Mantém retrocompatibilidade sem quebrar chamadas antigas.
262
+ * @type {boolean}
263
+ */
264
+ const DEPRECATION_WARN_EXECUTEQUERY_OPTIONS_IN_3RD_PARAM = true;
265
+
266
+ /**
267
+ * Limites para logs:
268
+ * - SQL_LOG_MAX: limita tamanho do SQL logado
269
+ * - PARAMS_LOG_MAX: limita quantidade de params logados (e profundidade em arrays/objetos)
270
+ */
271
+ const SQL_LOG_MAX = 800;
272
+ const PARAMS_LOG_MAX = 25;
273
+
274
+ const DB_MONITOR_LOG_ROTATE_BYTES = Math.max(0, DB_MONITOR_LOG_ROTATE_MB * 1024 * 1024);
275
+
276
+ /**
277
+ * Proteção para Map de fingerprints não crescer indefinidamente.
278
+ * MAX_FINGERPRINTS = max(sampleSize, topN*20, 500)
279
+ */
280
+ const MAX_FINGERPRINTS = Math.max(DB_STATS_SAMPLE_SIZE, DB_STATS_TOP_N * 20, 500);
281
+
282
+ /**
283
+ * Buckets do histograma de latência em milissegundos.
284
+ * Usado para calcular percentis aproximados sem ordenar todas as amostras.
285
+ */
286
+ const HISTOGRAM_BUCKETS = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
287
+
288
+ /**
289
+ * Symbol usado como "tag" para marcar objetos (pool/connection) já wrapados.
290
+ * Evita wrap duplicado e permite recuperar funções originais.
291
+ */
292
+ const MONITOR_TAG = Symbol('dbMonitorWrapped');
293
+
294
+ /**
295
+ * Objeto consolidado com o estado/configuração do monitor.
296
+ * @type {{
297
+ * enabled: boolean,
298
+ * slowMs: number,
299
+ * logEveryQuery: boolean,
300
+ * topN: number,
301
+ * sampleSize: number,
302
+ * slowExplain: boolean,
303
+ * logPath: string,
304
+ * logRotateBytes: number,
305
+ * logKeep: number,
306
+ * snapshotEveryMs: number
307
+ * }}
308
+ */
309
+ const monitorConfig = {
310
+ enabled: DB_MONITOR_ENABLED,
311
+ slowMs: DB_SLOW_QUERY_MS,
312
+ logEveryQuery: DB_LOG_EVERY_QUERY,
313
+ topN: DB_STATS_TOP_N,
314
+ sampleSize: DB_STATS_SAMPLE_SIZE,
315
+ slowExplain: DB_SLOW_EXPLAIN,
316
+ logPath: DB_MONITOR_LOG_PATH,
317
+ logRotateBytes: DB_MONITOR_LOG_ROTATE_BYTES,
318
+ logKeep: DB_MONITOR_LOG_KEEP,
319
+ snapshotEveryMs: DB_MONITOR_SNAPSHOT_EVERY_MS,
320
+ };
321
+
322
+ /**
323
+ * Regex usadas para mascarar dados sensíveis em logs.
324
+ * - EMAIL_REGEX: mascara emails (ex: a***@dominio.com)
325
+ * - JWT_REGEX: detecta tokens JWT
326
+ * - TOKEN_LIKE_REGEX: tokens longos (api keys etc)
327
+ */
328
+ const EMAIL_REGEX = /([A-Z0-9._%+-]+)@([A-Z0-9.-]+\.[A-Z]{2,})/i;
329
+ const JWT_REGEX = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
330
+ const TOKEN_LIKE_REGEX = /^[A-Za-z0-9-_=+.]{20,}$/;
331
+
332
+ /**
333
+ * @typedef {Object} FingerprintEntry
334
+ * @property {string} fingerprint Identificador da query (hash do SQL normalizado)
335
+ * @property {string} normalizedSql SQL normalizado e truncado (para agrupamento)
336
+ * @property {string|null} type Tipo da query (SELECT/INSERT/UPDATE/DELETE/DDL/OTHER)
337
+ * @property {string|null} table Tabela extraída (quando possível)
338
+ * @property {number} count Total de execuções
339
+ * @property {number} errorCount Total de erros
340
+ * @property {number} slowCount Total de slow queries
341
+ * @property {number} totalMs Soma total em ms
342
+ * @property {number} maxMs Maior duração observada
343
+ * @property {number|null} minMs Menor duração observada
344
+ * @property {number} lastMs Duração da última execução
345
+ * @property {number} lastSeenAt Timestamp ms da última vez vista
346
+ * @property {number|null} lastRowCount Último rowCount detectado (se aplicável)
347
+ * @property {number|null} lastAffectedRows Último affectedRows detectado (se aplicável)
348
+ */
349
+
350
+ /**
351
+ * @typedef {Object} DbStats
352
+ * @property {boolean} enabled Se o monitor está habilitado
353
+ * @property {number} startedAt Timestamp ms da inicialização do monitor
354
+ * @property {number} lastResetAt Timestamp ms do último reset
355
+ * @property {{total:number, error:number, slow:number}} counters Contadores globais
356
+ * @property {number} inFlight Queries em andamento (monitor)
357
+ * @property {number} maxInFlight Pico de concorrência observado
358
+ * @property {number} durationTotal Soma de todas durações (ms)
359
+ * @property {number|null} durationMin Menor duração (ms)
360
+ * @property {number|null} durationMax Maior duração (ms)
361
+ * @property {number} durationCount Quantidade de medições
362
+ * @property {number[]} samples Amostra circular de durações (ms)
363
+ * @property {number} sampleCursor Cursor para sobrescrever amostra
364
+ * @property {number[]} histogramBuckets Buckets do histograma
365
+ * @property {number[]} histogramCounts Contadores do histograma (len = buckets+1)
366
+ * @property {Map<string, FingerprintEntry>} fingerprints Métricas por query agrupada
367
+ */
368
+
369
+ /** @type {DbStats} */
370
+ let dbStats = createEmptyStats();
371
+
372
+ /**
373
+ * Contador de in-flight para métrica externa (observability).
374
+ * Separado do dbStats (monitor) pois métricas podem estar ativas mesmo com monitor desligado.
375
+ * @type {number}
376
+ */
377
+ let dbInFlightMetric = 0;
378
+
379
+ /**
380
+ * Cria o estado inicial de estatísticas do monitor.
381
+ * Sempre que resetamos, os contadores e agregados voltam ao início.
382
+ *
383
+ * @returns {DbStats}
384
+ */
385
+ function createEmptyStats() {
386
+ return {
387
+ enabled: monitorConfig.enabled,
388
+ startedAt: Date.now(),
389
+ lastResetAt: Date.now(),
390
+ counters: {
391
+ total: 0,
392
+ error: 0,
393
+ slow: 0,
394
+ },
395
+ inFlight: 0,
396
+ maxInFlight: 0,
397
+ durationTotal: 0,
398
+ durationMin: null,
399
+ durationMax: null,
400
+ durationCount: 0,
401
+ samples: [],
402
+ sampleCursor: 0,
403
+ histogramBuckets: HISTOGRAM_BUCKETS.slice(),
404
+ histogramCounts: new Array(HISTOGRAM_BUCKETS.length + 1).fill(0),
405
+ fingerprints: new Map(),
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Cria um logger de monitoramento que grava JSON line (1 evento por linha) em arquivo.
411
+ * Inclui:
412
+ * - queue interna para não bloquear o fluxo (stream backpressure)
413
+ * - criação automática do diretório
414
+ * - rotação por tamanho (rotateBytes)
415
+ * - retenção (keep)
416
+ *
417
+ * Se disabled, retorna um "noop logger" (log() não faz nada).
418
+ *
419
+ * @param {{enabled:boolean, logPath:string, rotateBytes:number, keep:number}} cfg
420
+ * @returns {{log: (entry:any) => void}}
421
+ */
422
+ function createDbMonitorLogger({ enabled, logPath, rotateBytes, keep }) {
423
+ if (!enabled) {
424
+ return {
425
+ log: () => {},
426
+ };
427
+ }
428
+
429
+ const dir = path.dirname(logPath);
430
+ let stream = null;
431
+ let streamSize = 0;
432
+ let initializing = null;
433
+ let processing = false;
434
+ let rotating = false;
435
+ const queue = [];
436
+
437
+ /**
438
+ * Remove arquivo ignorando ENOENT (não existe).
439
+ * @param {string} target
440
+ */
441
+ const safeUnlink = async (target) => {
442
+ try {
443
+ await fsPromises.unlink(target);
444
+ } catch (error) {
445
+ if (error.code !== 'ENOENT') {
446
+ throw error;
447
+ }
448
+ }
449
+ };
450
+
451
+ /**
452
+ * Renomeia arquivo ignorando ENOENT.
453
+ * @param {string} from
454
+ * @param {string} to
455
+ */
456
+ const safeRename = async (from, to) => {
457
+ try {
458
+ await fsPromises.rename(from, to);
459
+ } catch (error) {
460
+ if (error.code !== 'ENOENT') {
461
+ throw error;
462
+ }
463
+ }
464
+ };
465
+
466
+ /**
467
+ * Garante que o stream de escrita está aberto.
468
+ * Faz:
469
+ * - mkdir do diretório
470
+ * - stat para continuar contagem do tamanho
471
+ * - abre stream em append
472
+ */
473
+ const ensureStream = async () => {
474
+ if (stream) {
475
+ return;
476
+ }
477
+ if (initializing) {
478
+ await initializing;
479
+ return;
480
+ }
481
+
482
+ initializing = (async () => {
483
+ await fsPromises.mkdir(dir, { recursive: true });
484
+ try {
485
+ const stat = await fsPromises.stat(logPath);
486
+ streamSize = stat.size;
487
+ } catch (error) {
488
+ if (error.code !== 'ENOENT') {
489
+ throw error;
490
+ }
491
+ streamSize = 0;
492
+ }
493
+ stream = fs.createWriteStream(logPath, { flags: 'a' });
494
+ stream.on('error', (error) => {
495
+ logger.error('Erro no stream do monitor de banco.', {
496
+ errorMessage: error.message,
497
+ });
498
+ stream = null;
499
+ });
500
+ })();
501
+
502
+ try {
503
+ await initializing;
504
+ } finally {
505
+ initializing = null;
506
+ }
507
+ };
508
+
509
+ /**
510
+ * Fecha stream atual.
511
+ */
512
+ const closeStream = async () => {
513
+ if (!stream) {
514
+ return;
515
+ }
516
+ const current = stream;
517
+ stream = null;
518
+ await new Promise((resolve) => current.end(resolve));
519
+ };
520
+
521
+ /**
522
+ * Rotaciona logs:
523
+ * - fecha stream
524
+ * - renomeia `logPath` -> `logPath.1`, empurra as versões antigas
525
+ * - remove `logPath.keep` se existir
526
+ */
527
+ const rotateLogs = async () => {
528
+ if (rotating || rotateBytes <= 0) {
529
+ return;
530
+ }
531
+ rotating = true;
532
+ try {
533
+ await closeStream();
534
+ if (keep === 0) {
535
+ await safeUnlink(logPath);
536
+ } else {
537
+ await safeUnlink(`${logPath}.${keep}`);
538
+ for (let i = keep - 1; i >= 1; i -= 1) {
539
+ await safeRename(`${logPath}.${i}`, `${logPath}.${i + 1}`);
540
+ }
541
+ await safeRename(logPath, `${logPath}.1`);
542
+ }
543
+ } catch (error) {
544
+ logger.error('Erro ao rotacionar log do monitor de banco.', {
545
+ errorMessage: error.message,
546
+ });
547
+ } finally {
548
+ streamSize = 0;
549
+ rotating = false;
550
+ }
551
+ };
552
+
553
+ /**
554
+ * Escreve uma linha no arquivo, respeitando backpressure do stream.
555
+ * @param {string} line
556
+ */
557
+ const writeLine = async (line) => {
558
+ await ensureStream();
559
+ if (!stream) {
560
+ return;
561
+ }
562
+ const payload = `${line}\n`;
563
+ const canWrite = stream.write(payload);
564
+ streamSize += Buffer.byteLength(payload);
565
+ if (rotateBytes > 0 && streamSize >= rotateBytes) {
566
+ await rotateLogs();
567
+ }
568
+ if (!canWrite && stream) {
569
+ await once(stream, 'drain');
570
+ }
571
+ };
572
+
573
+ /**
574
+ * Processa fila de logs sequencialmente.
575
+ * Evita concorrência e garante ordem razoável.
576
+ */
577
+ const processQueue = async () => {
578
+ try {
579
+ while (queue.length > 0) {
580
+ const line = queue.shift();
581
+ if (!line) {
582
+ continue;
583
+ }
584
+ await writeLine(line);
585
+ }
586
+ } catch (error) {
587
+ logger.error('Erro ao gravar log do monitor de banco.', {
588
+ errorMessage: error.message,
589
+ });
590
+ queue.length = 0;
591
+ } finally {
592
+ processing = false;
593
+ if (queue.length > 0 && !processing) {
594
+ processing = true;
595
+ setImmediate(() => {
596
+ processQueue().catch(() => {});
597
+ });
598
+ }
599
+ }
600
+ };
601
+
602
+ /**
603
+ * Enfileira um evento do monitor (JSON).
604
+ * @param {any} entry
605
+ */
606
+ const enqueue = (entry) => {
607
+ try {
608
+ queue.push(JSON.stringify(entry));
609
+ } catch (error) {
610
+ queue.push(
611
+ JSON.stringify({
612
+ ts: new Date().toISOString(),
613
+ event: 'logger_error',
614
+ errorMessage: error.message,
615
+ }),
616
+ );
617
+ }
618
+ if (!processing) {
619
+ processing = true;
620
+ setImmediate(() => {
621
+ processQueue().catch(() => {});
622
+ });
623
+ }
624
+ };
625
+
626
+ return {
627
+ log: enqueue,
628
+ };
629
+ }
630
+
631
+ const dbMonitorLogger = createDbMonitorLogger({
632
+ enabled: monitorConfig.enabled,
633
+ logPath: monitorConfig.logPath,
634
+ rotateBytes: monitorConfig.logRotateBytes,
635
+ keep: monitorConfig.logKeep,
636
+ });
637
+
638
+ /**
639
+ * Reseta as estatísticas do monitor (não afeta o pool).
640
+ * Útil para "zerar" o dashboard sem reiniciar o app.
641
+ */
642
+ export function resetDbStats() {
643
+ dbStats = createEmptyStats();
644
+ }
645
+
646
+ /**
647
+ * Retorna um snapshot das estatísticas atuais.
648
+ * Inclui:
649
+ * - contadores totais/slow/erro
650
+ * - concorrência (inFlight/maxInFlight)
651
+ * - latências (avg/min/max/p50/p95/p99)
652
+ * - histograma de buckets
653
+ * - top queries (mais lentas e mais frequentes)
654
+ *
655
+ * @returns {object}
656
+ */
657
+ export function getDbStats() {
658
+ const now = Date.now();
659
+ const sampleCount = dbStats.samples.length;
660
+ const percentiles = calculatePercentiles();
661
+ const histogram = {
662
+ buckets: dbStats.histogramBuckets.slice(),
663
+ counts: dbStats.histogramCounts.slice(),
664
+ };
665
+ const avgMs = dbStats.durationCount ? dbStats.durationTotal / dbStats.durationCount : null;
666
+
667
+ const fingerprintStats = Array.from(dbStats.fingerprints.values()).map((entry) => ({
668
+ fingerprint: entry.fingerprint,
669
+ normalizedSql: entry.normalizedSql,
670
+ type: entry.type,
671
+ table: entry.table,
672
+ count: entry.count,
673
+ errorCount: entry.errorCount,
674
+ slowCount: entry.slowCount,
675
+ avgMs: Number((entry.totalMs / entry.count).toFixed(2)),
676
+ maxMs: Number(entry.maxMs.toFixed(2)),
677
+ lastMs: Number(entry.lastMs.toFixed(2)),
678
+ lastSeenAt: new Date(entry.lastSeenAt).toISOString(),
679
+ }));
680
+
681
+ const topN = monitorConfig.topN;
682
+ const topSlow = fingerprintStats
683
+ .slice()
684
+ .sort((a, b) => b.maxMs - a.maxMs)
685
+ .slice(0, topN);
686
+
687
+ const topFrequent = fingerprintStats
688
+ .slice()
689
+ .sort((a, b) => b.count - a.count)
690
+ .slice(0, topN);
691
+
692
+ return {
693
+ enabled: monitorConfig.enabled,
694
+ config: {
695
+ slowMs: monitorConfig.slowMs,
696
+ logEveryQuery: monitorConfig.logEveryQuery,
697
+ topN: monitorConfig.topN,
698
+ sampleSize: monitorConfig.sampleSize,
699
+ slowExplain: monitorConfig.slowExplain,
700
+ },
701
+ counters: { ...dbStats.counters },
702
+ concurrency: {
703
+ inFlight: dbStats.inFlight,
704
+ maxInFlight: dbStats.maxInFlight,
705
+ },
706
+ latencyMs: {
707
+ avg: avgMs !== null ? Number(avgMs.toFixed(2)) : null,
708
+ min: dbStats.durationMin !== null ? Number(dbStats.durationMin.toFixed(2)) : null,
709
+ max: dbStats.durationMax !== null ? Number(dbStats.durationMax.toFixed(2)) : null,
710
+ p50: percentiles.p50,
711
+ p95: percentiles.p95,
712
+ p99: percentiles.p99,
713
+ samples: sampleCount,
714
+ },
715
+ histogram,
716
+ topSlow,
717
+ topFrequent,
718
+ startedAt: new Date(dbStats.startedAt).toISOString(),
719
+ lastResetAt: new Date(dbStats.lastResetAt).toISOString(),
720
+ now: new Date(now).toISOString(),
721
+ };
722
+ }
723
+
724
+ /**
725
+ * Se habilitado, grava "snapshots" periódicos no arquivo de monitor.
726
+ * Útil para investigar picos após o ocorrido, mesmo sem dashboard em tempo real.
727
+ */
728
+ if (monitorConfig.enabled && monitorConfig.snapshotEveryMs > 0) {
729
+ const snapshotTimer = setInterval(() => {
730
+ const entry = buildMonitorLogEntry({ event: 'snapshot' });
731
+ entry.stats = getDbStats();
732
+ dbMonitorLogger.log(entry);
733
+ }, monitorConfig.snapshotEveryMs);
734
+
735
+ // unref: permite o processo encerrar mesmo com timer ativo
736
+ if (typeof snapshotTimer.unref === 'function') {
737
+ snapshotTimer.unref();
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Calcula percentis aproximados (p50/p95/p99) usando histograma.
743
+ * Não precisa ordenar todas as amostras, é O(buckets).
744
+ *
745
+ * @returns {{p50:number|null, p95:number|null, p99:number|null}}
746
+ */
747
+ function calculatePercentiles() {
748
+ const total = dbStats.durationCount;
749
+ if (!total) {
750
+ return { p50: null, p95: null, p99: null };
751
+ }
752
+
753
+ const targets = [
754
+ { key: 'p50', target: Math.ceil(total * 0.5) },
755
+ { key: 'p95', target: Math.ceil(total * 0.95) },
756
+ { key: 'p99', target: Math.ceil(total * 0.99) },
757
+ ];
758
+
759
+ const results = {
760
+ p50: null,
761
+ p95: null,
762
+ p99: null,
763
+ };
764
+
765
+ let cumulative = 0;
766
+ const buckets = dbStats.histogramBuckets;
767
+ const counts = dbStats.histogramCounts;
768
+
769
+ for (let i = 0; i < counts.length; i += 1) {
770
+ const count = counts[i];
771
+ if (!count) continue;
772
+
773
+ cumulative += count;
774
+
775
+ for (const item of targets) {
776
+ if (results[item.key] !== null) continue;
777
+ if (cumulative >= item.target) {
778
+ if (i < buckets.length) results[item.key] = buckets[i];
779
+ else {
780
+ results[item.key] = dbStats.durationMax !== null ? Number(dbStats.durationMax.toFixed(2)) : buckets[buckets.length - 1];
781
+ }
782
+ }
783
+ }
784
+
785
+ if (results.p50 !== null && results.p95 !== null && results.p99 !== null) break;
786
+ }
787
+
788
+ return results;
789
+ }
790
+
791
+ /**
792
+ * Trunca textos longos para evitar logs gigantes.
793
+ * Anexa o tamanho original ao final: "...[1234]"
794
+ *
795
+ * @param {unknown} value Texto/objeto a ser convertido em string
796
+ * @param {number} [maxLength=SQL_LOG_MAX]
797
+ * @returns {string}
798
+ */
799
+ function truncateText(value, maxLength = SQL_LOG_MAX) {
800
+ const text = String(value ?? '');
801
+ if (text.length <= maxLength) return text;
802
+ return `${text.slice(0, maxLength)}...[${text.length}]`;
803
+ }
804
+
805
+ /**
806
+ * Remove comentários do SQL para melhorar normalização/fingerprint.
807
+ * Suporta:
808
+ * - /* ... *\/
809
+ * - -- ...
810
+ * - # ...
811
+ *
812
+ * @param {unknown} sql
813
+ * @returns {string}
814
+ */
815
+ function stripSqlComments(sql) {
816
+ return String(sql ?? '')
817
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
818
+ .replace(/--.*$/gm, ' ')
819
+ .replace(/#.*$/gm, ' ');
820
+ }
821
+
822
+ /**
823
+ * Normaliza SQL para agrupamento e fingerprint:
824
+ * - remove comentários
825
+ * - substitui strings por '?'
826
+ * - substitui números por '?'
827
+ * - normaliza whitespace
828
+ * - retorna em UPPERCASE
829
+ *
830
+ * Importante: isso NÃO é parser SQL completo, é heurística prática para monitor.
831
+ *
832
+ * @param {unknown} sql
833
+ * @returns {string}
834
+ */
835
+ function normalizeSql(sql) {
836
+ let normalized = stripSqlComments(sql);
837
+ normalized = normalized.replace(/'(?:\\'|''|[^'])*'/g, '?');
838
+ normalized = normalized.replace(/"(?:\\"|""|[^"])*"/g, '?');
839
+ normalized = normalized.replace(/\b0x[0-9a-f]+\b/gi, '?');
840
+ normalized = normalized.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, '?');
841
+ normalized = normalized.replace(/\b\d+(\.\d+)?\b/g, '?');
842
+ normalized = normalized.replace(/\s+/g, ' ').trim();
843
+ return normalized.toUpperCase();
844
+ }
845
+
846
+ /**
847
+ * Detecta o tipo principal da query pelo primeiro token SQL.
848
+ * Isso é usado para:
849
+ * - métricas agregadas por tipo (SELECT/INSERT/UPDATE...)
850
+ * - heurísticas como "slowExplain apenas para SELECT"
851
+ *
852
+ * @param {unknown} sql
853
+ * @returns {'SELECT'|'INSERT'|'UPDATE'|'DELETE'|'DDL'|'OTHER'}
854
+ */
855
+ function getQueryType(sql) {
856
+ const cleaned = stripSqlComments(sql).trim().toUpperCase();
857
+ const [firstWord] = cleaned.split(/\s+/);
858
+ switch (firstWord) {
859
+ case 'SELECT':
860
+ case 'WITH':
861
+ case 'SHOW':
862
+ case 'DESC':
863
+ case 'DESCRIBE':
864
+ case 'EXPLAIN':
865
+ return 'SELECT';
866
+ case 'INSERT':
867
+ case 'REPLACE':
868
+ return 'INSERT';
869
+ case 'UPDATE':
870
+ return 'UPDATE';
871
+ case 'DELETE':
872
+ return 'DELETE';
873
+ case 'CREATE':
874
+ case 'ALTER':
875
+ case 'DROP':
876
+ case 'TRUNCATE':
877
+ case 'RENAME':
878
+ return 'DDL';
879
+ default:
880
+ return 'OTHER';
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Deduz a operação de escrita para métricas (write counter).
886
+ * Ex:
887
+ * - INSERT -> insert
888
+ * - INSERT ... ON DUPLICATE -> upsert
889
+ * - REPLACE -> replace
890
+ *
891
+ * @param {string} normalizedSql SQL normalizado
892
+ * @param {string} queryType Tipo deduzido
893
+ * @returns {'insert'|'upsert'|'replace'|'update'|'delete'|null}
894
+ */
895
+ function getWriteOperation(normalizedSql, queryType) {
896
+ if (!normalizedSql) return null;
897
+ if (queryType === 'INSERT') {
898
+ if (normalizedSql.startsWith('REPLACE')) return 'replace';
899
+ if (normalizedSql.includes('ON DUPLICATE KEY UPDATE')) return 'upsert';
900
+ return 'insert';
901
+ }
902
+ if (queryType === 'UPDATE') return 'update';
903
+ if (queryType === 'DELETE') return 'delete';
904
+ return null;
905
+ }
906
+
907
+ /**
908
+ * Extrai um nome de tabela do SQL (heurística).
909
+ * Funciona bem para queries simples (FROM/INTO/UPDATE).
910
+ * Pode falhar em SQL complexo, joins, subqueries, etc.
911
+ *
912
+ * @param {unknown} sql SQL original
913
+ * @param {string} queryType Tipo da query
914
+ * @returns {string|null} Nome da tabela ou null se não detectável
915
+ */
916
+ function extractTableName(sql, queryType) {
917
+ const cleaned = stripSqlComments(sql).replace(/\s+/g, ' ').trim();
918
+ let match = null;
919
+
920
+ if (queryType === 'SELECT') {
921
+ match = /FROM\s+([`"\[]?[\w.-]+[`"\]]?)/i.exec(cleaned);
922
+ } else if (queryType === 'INSERT') {
923
+ match = /INTO\s+([`"\[]?[\w.-]+[`"\]]?)/i.exec(cleaned);
924
+ } else if (queryType === 'UPDATE') {
925
+ match = /UPDATE\s+([`"\[]?[\w.-]+[`"\]]?)/i.exec(cleaned);
926
+ } else if (queryType === 'DELETE') {
927
+ match = /FROM\s+([`"\[]?[\w.-]+[`"\]]?)/i.exec(cleaned);
928
+ } else if (queryType === 'DDL') {
929
+ match = /TABLE\s+([`"\[]?[\w.-]+[`"\]]?)/i.exec(cleaned);
930
+ if (!match) {
931
+ match = /(DATABASE|SCHEMA)\s+([`"\[]?[\w.-]+[`"\]]?)/i.exec(cleaned);
932
+ }
933
+ }
934
+
935
+ if (!match) return null;
936
+ const raw = match[2] || match[1];
937
+ return raw ? raw.replace(/[`"\[\]]/g, '') : null;
938
+ }
939
+
940
+ /**
941
+ * Extrai SQL e params do formato suportado pelo mysql2:
942
+ * - execute(sql, params)
943
+ * - execute({sql, values}, params?)
944
+ *
945
+ * @param {any[]} args Args recebidos em execute/query
946
+ * @returns {{sql: string, params: any}}
947
+ */
948
+ function extractSqlAndParams(args) {
949
+ const first = args[0];
950
+ if (first && typeof first === 'object' && typeof first.sql === 'string') {
951
+ const params = first.values ?? args[1] ?? [];
952
+ return { sql: first.sql, params };
953
+ }
954
+ return { sql: first ?? '', params: args[1] ?? [] };
955
+ }
956
+
957
+ /**
958
+ * Implementação simples de FNV-1a (32-bit) para gerar hash estável.
959
+ * Serve para gerar fingerprint curto e barato.
960
+ *
961
+ * @param {string} input
962
+ * @returns {string} hash em hex (8 chars)
963
+ */
964
+ function fnv1aHash(input) {
965
+ let hash = 0x811c9dc5;
966
+ for (let i = 0; i < input.length; i += 1) {
967
+ hash ^= input.charCodeAt(i);
968
+ hash = (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
969
+ }
970
+ return hash.toString(16).padStart(8, '0');
971
+ }
972
+
973
+ /**
974
+ * Cria fingerprint estável baseado no SQL normalizado.
975
+ * @param {string} normalizedSql
976
+ * @returns {string}
977
+ */
978
+ function createFingerprint(normalizedSql) {
979
+ return `fp:${fnv1aHash(normalizedSql)}`;
980
+ }
981
+
982
+ /**
983
+ * Mascara strings sensíveis para logs.
984
+ * Regras:
985
+ * - email -> a***@dominio
986
+ * - jwt -> [JWT]
987
+ * - token longo -> [REDACTED:n]
988
+ * - strings enormes -> truncadas
989
+ *
990
+ * @param {string} value
991
+ * @returns {string}
992
+ */
993
+ function maskString(value) {
994
+ if (EMAIL_REGEX.test(value)) {
995
+ return value.replace(EMAIL_REGEX, (_, user, domain) => {
996
+ const maskedUser = user.length > 1 ? `${user[0]}***` : '*';
997
+ return `${maskedUser}@${domain}`;
998
+ });
999
+ }
1000
+ if (JWT_REGEX.test(value)) {
1001
+ return '[JWT]';
1002
+ }
1003
+ if (value.length > 40 && TOKEN_LIKE_REGEX.test(value) && !value.includes(' ')) {
1004
+ return `[REDACTED:${value.length}]`;
1005
+ }
1006
+ if (value.length > 120) {
1007
+ return `${value.slice(0, 40)}...[${value.length}]`;
1008
+ }
1009
+ return value;
1010
+ }
1011
+
1012
+ /**
1013
+ * Mascara valores de parâmetros recursivamente.
1014
+ * Isso evita vazar PII/tokens em logs, e reduz volume.
1015
+ *
1016
+ * @param {any} value
1017
+ * @param {number} [depth=0] Profundidade (limita recursão)
1018
+ * @returns {any} Valor mascarado/serializável
1019
+ */
1020
+ function maskParamValue(value, depth = 0) {
1021
+ if (value === undefined) return null;
1022
+ if (value === null) return null;
1023
+
1024
+ if (typeof value === 'string') return maskString(value);
1025
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
1026
+ if (typeof value === 'bigint') return value.toString();
1027
+ if (value instanceof Date) return value.toISOString();
1028
+ if (Buffer.isBuffer(value)) return `[Buffer:${value.length}]`;
1029
+
1030
+ if (Array.isArray(value)) {
1031
+ if (depth > 2) return `[Array:${value.length}]`;
1032
+ const truncated = value.slice(0, PARAMS_LOG_MAX).map((item) => maskParamValue(item, depth + 1));
1033
+ if (value.length > PARAMS_LOG_MAX) truncated.push(`...(+${value.length - PARAMS_LOG_MAX})`);
1034
+ return truncated;
1035
+ }
1036
+
1037
+ if (typeof value === 'object') {
1038
+ if (depth > 1) return '[Object]';
1039
+ const entries = Object.entries(value);
1040
+ const out = {};
1041
+ const limited = entries.slice(0, PARAMS_LOG_MAX);
1042
+ for (const [key, item] of limited) out[key] = maskParamValue(item, depth + 1);
1043
+ if (entries.length > PARAMS_LOG_MAX) out.__truncated = `+${entries.length - PARAMS_LOG_MAX}`;
1044
+ return out;
1045
+ }
1046
+
1047
+ return `[${typeof value}]`;
1048
+ }
1049
+
1050
+ /**
1051
+ * Mascara params (array, objeto ou valor único).
1052
+ * @param {any} params
1053
+ * @returns {any}
1054
+ */
1055
+ function maskParams(params) {
1056
+ if (params === undefined) return undefined;
1057
+ if (Array.isArray(params)) return params.map((param) => maskParamValue(param));
1058
+ if (typeof params === 'object' && params !== null) return maskParamValue(params);
1059
+ return maskParamValue(params);
1060
+ }
1061
+
1062
+ /**
1063
+ * Extrai estatísticas do resultado do mysql2.
1064
+ * mysql2 pode retornar:
1065
+ * - [rows, fields]
1066
+ * - OkPacket / ResultSetHeader com affectedRows
1067
+ *
1068
+ * @param {any} result
1069
+ * @returns {{rowCount: number|undefined, affectedRows: number|undefined}}
1070
+ */
1071
+ function extractResultStats(result) {
1072
+ if (result === undefined || result === null) {
1073
+ return { rowCount: undefined, affectedRows: undefined };
1074
+ }
1075
+
1076
+ let rows = result;
1077
+ const looksLikeFields = Array.isArray(result) && result.length === 2 && ((Array.isArray(result[1]) && (result[1].length === 0 || typeof result[1][0] === 'object')) || result[1] === undefined || result[1] === null);
1078
+
1079
+ if (looksLikeFields) {
1080
+ rows = result[0];
1081
+ }
1082
+
1083
+ let rowCount;
1084
+ let affectedRows;
1085
+
1086
+ if (Array.isArray(rows)) {
1087
+ rowCount = rows.length;
1088
+ } else if (rows && typeof rows === 'object') {
1089
+ if (typeof rows.affectedRows === 'number') affectedRows = rows.affectedRows;
1090
+ if (typeof rows.rowCount === 'number') rowCount = rows.rowCount;
1091
+ }
1092
+
1093
+ return { rowCount, affectedRows };
1094
+ }
1095
+
1096
+ /**
1097
+ * Registra a duração em amostra circular para inspeção rápida.
1098
+ * @param {number} durationMs
1099
+ */
1100
+ function recordSample(durationMs) {
1101
+ if (monitorConfig.sampleSize <= 0) return;
1102
+
1103
+ if (dbStats.samples.length < monitorConfig.sampleSize) {
1104
+ dbStats.samples.push(durationMs);
1105
+ return;
1106
+ }
1107
+
1108
+ const idx = dbStats.sampleCursor % monitorConfig.sampleSize;
1109
+ dbStats.samples[idx] = durationMs;
1110
+ dbStats.sampleCursor += 1;
1111
+ }
1112
+
1113
+ /**
1114
+ * Incrementa bucket do histograma com base na duração.
1115
+ * @param {number} durationMs
1116
+ */
1117
+ function recordHistogram(durationMs) {
1118
+ const buckets = dbStats.histogramBuckets;
1119
+ for (let i = 0; i < buckets.length; i += 1) {
1120
+ if (durationMs <= buckets[i]) {
1121
+ dbStats.histogramCounts[i] += 1;
1122
+ return;
1123
+ }
1124
+ }
1125
+ dbStats.histogramCounts[buckets.length] += 1;
1126
+ }
1127
+
1128
+ /**
1129
+ * Remove fingerprints antigas para limitar memória.
1130
+ * Estratégia: remove ~10% mais antigos quando passar do limite.
1131
+ */
1132
+ function maybePruneFingerprints() {
1133
+ if (dbStats.fingerprints.size <= MAX_FINGERPRINTS) return;
1134
+
1135
+ const entries = Array.from(dbStats.fingerprints.values()).sort((a, b) => a.lastSeenAt - b.lastSeenAt);
1136
+ const removeCount = Math.max(1, Math.ceil(entries.length * 0.1));
1137
+ for (let i = 0; i < removeCount; i += 1) {
1138
+ dbStats.fingerprints.delete(entries[i].fingerprint);
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Atualiza contadores globais e contadores por fingerprint.
1144
+ * @param {{
1145
+ * fingerprint: string,
1146
+ * normalizedSql: string,
1147
+ * type: string,
1148
+ * table: string|null,
1149
+ * durationMs: number,
1150
+ * ok: boolean,
1151
+ * rowCount: number|undefined,
1152
+ * affectedRows: number|undefined,
1153
+ * isSlow: boolean
1154
+ * }} payload
1155
+ */
1156
+ function recordStats({ fingerprint, normalizedSql, type, table, durationMs, ok, rowCount, affectedRows, isSlow }) {
1157
+ dbStats.counters.total += 1;
1158
+ if (!ok) dbStats.counters.error += 1;
1159
+ if (isSlow) dbStats.counters.slow += 1;
1160
+
1161
+ dbStats.durationCount += 1;
1162
+ dbStats.durationTotal += durationMs;
1163
+ dbStats.durationMin = dbStats.durationMin === null ? durationMs : Math.min(dbStats.durationMin, durationMs);
1164
+ dbStats.durationMax = dbStats.durationMax === null ? durationMs : Math.max(dbStats.durationMax, durationMs);
1165
+
1166
+ recordSample(durationMs);
1167
+ recordHistogram(durationMs);
1168
+
1169
+ let entry = dbStats.fingerprints.get(fingerprint);
1170
+ if (!entry) {
1171
+ entry = {
1172
+ fingerprint,
1173
+ normalizedSql: truncateText(normalizedSql, 600),
1174
+ type,
1175
+ table,
1176
+ count: 0,
1177
+ errorCount: 0,
1178
+ slowCount: 0,
1179
+ totalMs: 0,
1180
+ maxMs: 0,
1181
+ minMs: null,
1182
+ lastMs: 0,
1183
+ lastSeenAt: 0,
1184
+ lastRowCount: null,
1185
+ lastAffectedRows: null,
1186
+ };
1187
+ dbStats.fingerprints.set(fingerprint, entry);
1188
+ maybePruneFingerprints();
1189
+ }
1190
+
1191
+ entry.count += 1;
1192
+ if (!ok) entry.errorCount += 1;
1193
+ if (isSlow) entry.slowCount += 1;
1194
+
1195
+ entry.totalMs += durationMs;
1196
+ entry.maxMs = Math.max(entry.maxMs, durationMs);
1197
+ entry.minMs = entry.minMs === null ? durationMs : Math.min(entry.minMs, durationMs);
1198
+ entry.lastMs = durationMs;
1199
+ entry.lastSeenAt = Date.now();
1200
+
1201
+ if (rowCount !== undefined) entry.lastRowCount = rowCount;
1202
+ if (affectedRows !== undefined) entry.lastAffectedRows = affectedRows;
1203
+ }
1204
+
1205
+ /**
1206
+ * Monta um evento de log padronizado (JSON) para arquivo.
1207
+ * @param {object} payload
1208
+ * @returns {object}
1209
+ */
1210
+ function buildMonitorLogEntry({ event, durationMs, type, table, fingerprint, normalizedSql, sql, rowCount, affectedRows, traceId, error, params }) {
1211
+ const entry = {
1212
+ ts: new Date().toISOString(),
1213
+ event,
1214
+ durationMs: durationMs !== undefined && durationMs !== null ? Number(durationMs.toFixed(2)) : null,
1215
+ type: type ?? null,
1216
+ table: table ?? null,
1217
+ fingerprint: fingerprint ?? null,
1218
+ normalizedSql: normalizedSql ? truncateText(normalizedSql, 600) : null,
1219
+ sql: sql ? truncateText(sql) : null,
1220
+ rowCount: rowCount ?? null,
1221
+ affectedRows: affectedRows ?? null,
1222
+ traceId: traceId ?? null,
1223
+ errorCode: error?.code ?? null,
1224
+ errorMessage: error?.message ?? null,
1225
+ };
1226
+ if (params !== undefined) entry.params = params;
1227
+ return entry;
1228
+ }
1229
+
1230
+ /**
1231
+ * Recupera a implementação ORIGINAL de execute() para um executor (pool ou connection).
1232
+ * Isso é importante porque a gente "wrapa" execute/query para medir latência.
1233
+ * Em alguns casos precisamos rodar EXPLAIN com a função original para evitar recursão infinita.
1234
+ *
1235
+ * @param {any} executor Pool ou Connection
1236
+ * @returns {Function|null}
1237
+ */
1238
+ function getOriginalExecute(executor) {
1239
+ if (!executor) return null;
1240
+ if (executor === pool) return poolExecuteOriginal;
1241
+ if (executor[MONITOR_TAG]?.originalExecute) return executor[MONITOR_TAG].originalExecute;
1242
+ if (typeof executor.execute === 'function') return executor.execute.bind(executor);
1243
+ return null;
1244
+ }
1245
+
1246
+ /**
1247
+ * Recupera a implementação ORIGINAL de query() para um executor (pool ou connection).
1248
+ * @param {any} executor
1249
+ * @returns {Function|null}
1250
+ */
1251
+ function getOriginalQuery(executor) {
1252
+ if (!executor) return null;
1253
+ if (executor === pool) return poolQueryOriginal;
1254
+ if (executor[MONITOR_TAG]?.originalQuery) return executor[MONITOR_TAG].originalQuery;
1255
+ if (typeof executor.query === 'function') return executor.query.bind(executor);
1256
+ return null;
1257
+ }
1258
+
1259
+ /**
1260
+ * Executa EXPLAIN em SELECT lento (quando habilitado).
1261
+ * Observações:
1262
+ * - roda em background (setImmediate) para não atrasar a resposta principal
1263
+ * - usa função original para evitar "monitorar o explain do explain"
1264
+ *
1265
+ * @param {{sql:string, params:any, executor:any, traceId?:string}} payload
1266
+ */
1267
+ async function runExplain({ sql, params, executor, traceId }) {
1268
+ const original = getOriginalExecute(executor) || getOriginalQuery(executor);
1269
+ if (!original) return;
1270
+
1271
+ const explainSql = String(sql ?? '')
1272
+ .trim()
1273
+ .toUpperCase()
1274
+ .startsWith('EXPLAIN')
1275
+ ? sql
1276
+ : `EXPLAIN ${sql}`;
1277
+
1278
+ try {
1279
+ await original(explainSql, params);
1280
+ logger.debug('EXPLAIN para query lenta executado.', {
1281
+ traceId,
1282
+ normalizedSql: truncateText(normalizeSql(explainSql), 600),
1283
+ });
1284
+ } catch (error) {
1285
+ logger.warn('Falha ao executar EXPLAIN para query lenta.', {
1286
+ traceId,
1287
+ errorCode: error.code,
1288
+ errorMessage: error.message,
1289
+ });
1290
+ }
1291
+ }
1292
+
1293
+ /**
1294
+ * Executor monitorado:
1295
+ * - mede duração (hrtime)
1296
+ * - atualiza stats
1297
+ * - escreve logs (slow/error e opcionalmente "every query")
1298
+ * - emite métricas se METRICS_ACTIVE
1299
+ *
1300
+ * @param {{
1301
+ * executor: any,
1302
+ * originalFn: Function,
1303
+ * args: any[],
1304
+ * traceId?: string,
1305
+ * allowExplain?: boolean
1306
+ * }} cfg
1307
+ * @returns {Promise<any>}
1308
+ */
1309
+ async function runMonitored({ executor, originalFn, args, traceId, allowExplain = false }) {
1310
+ if (typeof originalFn !== 'function') {
1311
+ throw new Error('Executor inválido para query.');
1312
+ }
1313
+
1314
+ const shouldMonitor = monitorConfig.enabled;
1315
+ const shouldMeasure = shouldMonitor || METRICS_ACTIVE;
1316
+
1317
+ if (!shouldMeasure) {
1318
+ return originalFn(...args);
1319
+ }
1320
+
1321
+ const { sql, params } = extractSqlAndParams(args);
1322
+ const sqlText = String(sql ?? '');
1323
+ const start = process.hrtime.bigint();
1324
+
1325
+ // in-flight do monitor
1326
+ if (shouldMonitor) {
1327
+ dbStats.inFlight += 1;
1328
+ if (dbStats.inFlight > dbStats.maxInFlight) dbStats.maxInFlight = dbStats.inFlight;
1329
+ }
1330
+
1331
+ // in-flight da métrica externa
1332
+ if (METRICS_ACTIVE) {
1333
+ dbInFlightMetric += 1;
1334
+ setDbInFlight(dbInFlightMetric);
1335
+ }
1336
+
1337
+ let ok = false;
1338
+ let result;
1339
+ let error;
1340
+
1341
+ try {
1342
+ result = await originalFn(...args);
1343
+ ok = true;
1344
+ return result;
1345
+ } catch (err) {
1346
+ error = err;
1347
+ throw err;
1348
+ } finally {
1349
+ const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
1350
+
1351
+ if (shouldMonitor) dbStats.inFlight = Math.max(0, dbStats.inFlight - 1);
1352
+
1353
+ if (METRICS_ACTIVE) {
1354
+ dbInFlightMetric = Math.max(0, dbInFlightMetric - 1);
1355
+ setDbInFlight(dbInFlightMetric);
1356
+ }
1357
+
1358
+ const type = getQueryType(sqlText);
1359
+ const table = extractTableName(sqlText, type);
1360
+ const normalizedSql = normalizeSql(sqlText);
1361
+ const fingerprint = createFingerprint(normalizedSql);
1362
+ const writeOperation = getWriteOperation(normalizedSql, type);
1363
+ const isSlow = durationMs >= monitorConfig.slowMs;
1364
+ const { rowCount, affectedRows } = extractResultStats(result);
1365
+
1366
+ if (shouldMonitor) {
1367
+ recordStats({
1368
+ fingerprint,
1369
+ normalizedSql,
1370
+ type,
1371
+ table,
1372
+ durationMs,
1373
+ ok,
1374
+ rowCount,
1375
+ affectedRows,
1376
+ isSlow,
1377
+ });
1378
+ }
1379
+
1380
+ if (METRICS_ACTIVE) {
1381
+ recordDbQuery({ durationMs, type, table, ok, isSlow });
1382
+ if (!ok) recordError('db');
1383
+ if (ok && writeOperation) recordDbWrite({ operation: writeOperation, table });
1384
+ }
1385
+
1386
+ const baseLogData = {
1387
+ durationMs,
1388
+ type,
1389
+ table,
1390
+ fingerprint,
1391
+ normalizedSql,
1392
+ sql: sqlText,
1393
+ rowCount,
1394
+ affectedRows,
1395
+ traceId,
1396
+ };
1397
+
1398
+ // Log de query normal (opcional) — cuidado em produção
1399
+ if (shouldMonitor && monitorConfig.logEveryQuery && ok && !isSlow) {
1400
+ const maskedParams = maskParams(params);
1401
+ logger.debug('DB query executada.', {
1402
+ durationMs: Number(durationMs.toFixed(2)),
1403
+ type,
1404
+ table,
1405
+ fingerprint,
1406
+ normalizedSql: truncateText(normalizedSql, 600),
1407
+ sql: truncateText(sqlText),
1408
+ params: maskedParams,
1409
+ rowCount,
1410
+ affectedRows,
1411
+ traceId,
1412
+ });
1413
+ dbMonitorLogger.log(buildMonitorLogEntry({ event: 'query', ...baseLogData, params: maskedParams }));
1414
+ }
1415
+
1416
+ // Slow query
1417
+ if (shouldMonitor && isSlow) {
1418
+ logger.warn('DB query lenta detectada.', {
1419
+ durationMs: Number(durationMs.toFixed(2)),
1420
+ type,
1421
+ table,
1422
+ fingerprint,
1423
+ normalizedSql: truncateText(normalizedSql, 600),
1424
+ sql: truncateText(sqlText),
1425
+ rowCount,
1426
+ affectedRows,
1427
+ traceId,
1428
+ });
1429
+ dbMonitorLogger.log(buildMonitorLogEntry({ event: 'slow', ...baseLogData }));
1430
+
1431
+ if (allowExplain && monitorConfig.slowExplain && type === 'SELECT') {
1432
+ setImmediate(() => {
1433
+ runExplain({ sql: sqlText, params, executor, traceId });
1434
+ });
1435
+ }
1436
+ }
1437
+
1438
+ // Erro
1439
+ if (shouldMonitor && !ok) {
1440
+ logger.error('Erro na consulta SQL.', {
1441
+ durationMs: Number(durationMs.toFixed(2)),
1442
+ type,
1443
+ table,
1444
+ fingerprint,
1445
+ normalizedSql: truncateText(normalizedSql, 600),
1446
+ sql: truncateText(sqlText),
1447
+ errorCode: error?.code,
1448
+ errorMessage: error?.message,
1449
+ traceId,
1450
+ });
1451
+ dbMonitorLogger.log(buildMonitorLogEntry({ event: 'error', ...baseLogData, error }));
1452
+ }
1453
+ }
1454
+ }
1455
+
1456
+ /**
1457
+ * "Wrapa" uma conexão individual retornada por pool.getConnection()
1458
+ * para que connection.execute/query também sejam monitorados.
1459
+ *
1460
+ * Importante:
1461
+ * - evita wrap duplicado (MONITOR_TAG.wrapped)
1462
+ * - salva referência das funções originais (para fallback)
1463
+ *
1464
+ * @param {import('mysql2/promise').PoolConnection} connection
1465
+ * @returns {import('mysql2/promise').PoolConnection}
1466
+ */
1467
+ function wrapConnection(connection) {
1468
+ if (!connection || connection[MONITOR_TAG]?.wrapped) {
1469
+ return connection;
1470
+ }
1471
+
1472
+ const originalExecute = connection.execute?.bind(connection);
1473
+ const originalQuery = connection.query?.bind(connection);
1474
+
1475
+ if (typeof originalExecute === 'function') {
1476
+ connection.execute = (...args) =>
1477
+ runMonitored({
1478
+ executor: connection,
1479
+ originalFn: originalExecute,
1480
+ args,
1481
+ traceId: connection.__traceId,
1482
+ allowExplain: true,
1483
+ });
1484
+ }
1485
+
1486
+ if (typeof originalQuery === 'function') {
1487
+ connection.query = (...args) =>
1488
+ runMonitored({
1489
+ executor: connection,
1490
+ originalFn: originalQuery,
1491
+ args,
1492
+ traceId: connection.__traceId,
1493
+ allowExplain: true,
1494
+ });
1495
+ }
1496
+
1497
+ connection[MONITOR_TAG] = {
1498
+ wrapped: true,
1499
+ originalExecute,
1500
+ originalQuery,
1501
+ };
1502
+
1503
+ return connection;
1504
+ }
1505
+
1506
+ /**
1507
+ * Referências das funções originais do pool (antes do wrap).
1508
+ * Usadas para:
1509
+ * - evitar recursão
1510
+ * - permitir EXPLAIN e execuções internas sem duplicar medição
1511
+ */
1512
+ let poolExecuteOriginal;
1513
+ let poolQueryOriginal;
1514
+ let poolGetConnectionOriginal;
1515
+
1516
+ const poolMonitorState = pool[MONITOR_TAG];
1517
+
1518
+ // Se já estava wrapado (ex: hot reload), reaproveita originais
1519
+ if (poolMonitorState?.wrapped) {
1520
+ poolExecuteOriginal = poolMonitorState.originalExecute || pool.execute.bind(pool);
1521
+ poolQueryOriginal = poolMonitorState.originalQuery || pool.query.bind(pool);
1522
+ poolGetConnectionOriginal = poolMonitorState.originalGetConnection || pool.getConnection.bind(pool);
1523
+ } else {
1524
+ // Primeira vez: salva originais e faz wrap
1525
+ poolExecuteOriginal = pool.execute.bind(pool);
1526
+ poolQueryOriginal = pool.query.bind(pool);
1527
+ poolGetConnectionOriginal = pool.getConnection.bind(pool);
1528
+
1529
+ pool.execute = (...args) =>
1530
+ runMonitored({
1531
+ executor: pool,
1532
+ originalFn: poolExecuteOriginal,
1533
+ args,
1534
+ });
1535
+
1536
+ pool.query = (...args) =>
1537
+ runMonitored({
1538
+ executor: pool,
1539
+ originalFn: poolQueryOriginal,
1540
+ args,
1541
+ });
1542
+
1543
+ pool.getConnection = async (...args) => {
1544
+ const connection = await poolGetConnectionOriginal(...args);
1545
+ return wrapConnection(connection);
1546
+ };
1547
+
1548
+ pool[MONITOR_TAG] = {
1549
+ wrapped: true,
1550
+ originalExecute: poolExecuteOriginal,
1551
+ originalQuery: poolQueryOriginal,
1552
+ originalGetConnection: poolGetConnectionOriginal,
1553
+ };
1554
+ }
1555
+
1556
+ /**
1557
+ * Valida conectividade do banco no boot:
1558
+ * - pega conexão do pool
1559
+ * - executa ping
1560
+ * - devolve conexão
1561
+ *
1562
+ * Se falhar, encerra o processo (fail fast).
1563
+ *
1564
+ * @returns {Promise<void>}
1565
+ */
1566
+ async function validateConnection() {
1567
+ try {
1568
+ const connection = await pool.getConnection();
1569
+ await connection.ping();
1570
+ connection.release();
1571
+ logger.info('Pool de conexões com o MySQL criado e testado com sucesso.');
1572
+ } catch (error) {
1573
+ logger.error('Erro ao conectar ao MySQL:', error.message);
1574
+ process.exit(1);
1575
+ }
1576
+ }
1577
+
1578
+ /**
1579
+ * Evita validar conexão quando rodando scripts de init/migration.
1580
+ * @type {boolean}
1581
+ */
1582
+ const isInitScript = process.argv[1]?.endsWith(`${path.sep}database${path.sep}init.js`);
1583
+ if (!isInitScript) {
1584
+ validateConnection();
1585
+ }
1586
+
1587
+ /**
1588
+ * Encerra o pool de conexões do MySQL.
1589
+ * Importante para desligamento gracioso (SIGTERM/SIGINT).
1590
+ *
1591
+ * @returns {Promise<void>}
1592
+ */
1593
+ export async function closePool() {
1594
+ try {
1595
+ await pool.end();
1596
+ logger.info('Pool de conexões MySQL encerrado com sucesso.');
1597
+ } catch (error) {
1598
+ logger.error('Erro ao encerrar pool de conexões:', error.message);
1599
+ process.exit(1);
1600
+ }
1601
+ }
1602
+
1603
+ /**
1604
+ * Guarda para evitar shutdown duplicado (SIGINT + SIGTERM, etc).
1605
+ * @type {boolean}
1606
+ */
1607
+ let isClosing = false;
1608
+
1609
+ /**
1610
+ * Desligamento gracioso:
1611
+ * - garante execução única
1612
+ * - fecha pool
1613
+ * - encerra processo
1614
+ *
1615
+ * @param {string} signal Nome do sinal recebido (SIGINT/SIGTERM)
1616
+ * @returns {Promise<void>}
1617
+ */
1618
+ async function shutdown(signal) {
1619
+ if (isClosing) return;
1620
+ isClosing = true;
1621
+
1622
+ logger.info(`Encerrando aplicação (${signal}).`);
1623
+ try {
1624
+ await closePool();
1625
+ } finally {
1626
+ process.exit(0);
1627
+ }
1628
+ }
1629
+
1630
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1631
+ process.on('SIGINT', () => shutdown('SIGINT'));
1632
+
1633
+ /**
1634
+ * Erro padrão para operações de banco.
1635
+ * Inclui metadados úteis para debug:
1636
+ * - errorCode/errorNumber/sqlState (MySQL)
1637
+ * - sql/params originais (cuidado ao exibir isso em logs externos)
1638
+ */
1639
+ export class DatabaseError extends Error {
1640
+ /**
1641
+ * @param {string} message Mensagem de alto nível
1642
+ * @param {any} originalError Erro original do mysql2
1643
+ * @param {string} sql SQL executado
1644
+ * @param {any} params Parâmetros utilizados
1645
+ */
1646
+ constructor(message, originalError, sql, params) {
1647
+ super(message);
1648
+ this.name = 'DatabaseError';
1649
+ this.originalError = originalError;
1650
+ this.sql = sql;
1651
+ this.params = params;
1652
+ this.errorCode = originalError?.code;
1653
+ this.errorNumber = originalError?.errno;
1654
+ this.sqlState = originalError?.sqlState;
1655
+ }
1656
+ }
1657
+
1658
+ const VALID_TABLES = Object.values(TABLES);
1659
+
1660
+ /**
1661
+ * Valida se o nome da tabela está na allow-list.
1662
+ * Protege contra uso indevido de tableName vindo de input externo.
1663
+ *
1664
+ * @param {string} tableName
1665
+ * @throws {Error} Se a tabela não for permitida
1666
+ */
1667
+ export function validateTableName(tableName) {
1668
+ if (!VALID_TABLES.includes(tableName)) {
1669
+ throw new Error(`Tabela inválida: ${tableName}`);
1670
+ }
1671
+ }
1672
+
1673
+ /**
1674
+ * Converte undefined em null para parâmetros SQL.
1675
+ * mysql2 não lida bem com undefined em binds.
1676
+ *
1677
+ * @param {Array<any>} params
1678
+ * @returns {Array<any>}
1679
+ */
1680
+ export function sanitizeParams(params) {
1681
+ return params.map((param) => (param === undefined ? null : param));
1682
+ }
1683
+
1684
+ /**
1685
+ * Executa uma consulta SQL (execute) com:
1686
+ * - sanitização de parâmetros (undefined -> null)
1687
+ * - suporte a pool ou conexão (em transações)
1688
+ * - suporte a traceId (correlação)
1689
+ * - monitor/métricas (quando habilitado)
1690
+ *
1691
+ * Assinatura atual (recomendada):
1692
+ * executeQuery(sql, params, connection, options)
1693
+ *
1694
+ * Compatibilidade (depreciada):
1695
+ * executeQuery(sql, params, options) // options como 3º parâmetro
1696
+ *
1697
+ * @param {string} sql SQL a executar
1698
+ * @param {Array<any>} [params=[]] Parâmetros do bind
1699
+ * @param {import('mysql2/promise').PoolConnection|null} [connection=null] Conexão opcional (transação)
1700
+ * @param {{traceId?: string}|null} [options] Opções (ex: traceId)
1701
+ * @returns {Promise<any>} Resultado (rows ou ok packet)
1702
+ */
1703
+ export async function executeQuery(sql, params = [], connection = null, options = null) {
1704
+ // Compat: options no 3º parâmetro (depreciado)
1705
+ if (connection && !options && isValidExecuteOptions(connection)) {
1706
+ if (DEPRECATION_WARN_EXECUTEQUERY_OPTIONS_IN_3RD_PARAM) {
1707
+ logger.warn('executeQuery(): assinatura com options no 3º parâmetro está depreciada. Use executeQuery(sql, params, connection, options).');
1708
+ }
1709
+ options = connection;
1710
+ connection = null;
1711
+ }
1712
+
1713
+ let executor = pool;
1714
+ let traceId = options && typeof options === 'object' ? options.traceId : undefined;
1715
+
1716
+ const isConnection = connection && (typeof connection.execute === 'function' || typeof connection.query === 'function');
1717
+
1718
+ if (connection) {
1719
+ if (isConnection) {
1720
+ executor = connection;
1721
+ traceId = traceId || connection.__traceId;
1722
+ } else {
1723
+ throw new Error('Parâmetro connection inválido em executeQuery. Informe uma conexão MySQL2 válida ou passe options no 4º parâmetro.');
1724
+ }
1725
+ }
1726
+
1727
+ const sanitizedParams = sanitizeParams(params);
1728
+ const originalExecute = getOriginalExecute(executor);
1729
+
1730
+ // Fallback (caso executor não esteja wrapado)
1731
+ if (!originalExecute || typeof originalExecute !== 'function') {
1732
+ try {
1733
+ const [results] = await executor.execute(sql, sanitizedParams);
1734
+ return results;
1735
+ } catch (error) {
1736
+ logger.error('Erro na consulta SQL.', {
1737
+ normalizedSql: truncateText(normalizeSql(sql), 600),
1738
+ sql: truncateText(sql),
1739
+ errorCode: error.code,
1740
+ errorMessage: error.message,
1741
+ traceId,
1742
+ });
1743
+ if (METRICS_ACTIVE) recordError('db');
1744
+ throw new DatabaseError(`Erro na execução da consulta: ${error.message}`, error, sql, params);
1745
+ }
1746
+ }
1747
+
1748
+ // Otimização: se nada estiver ativo, executa direto
1749
+ if (!monitorConfig.enabled && !METRICS_ACTIVE) {
1750
+ try {
1751
+ const [results] = await executor.execute(sql, sanitizedParams);
1752
+ return results;
1753
+ } catch (error) {
1754
+ logger.error('Erro na consulta SQL.', {
1755
+ normalizedSql: truncateText(normalizeSql(sql), 600),
1756
+ sql: truncateText(sql),
1757
+ errorCode: error.code,
1758
+ errorMessage: error.message,
1759
+ traceId,
1760
+ });
1761
+ throw new DatabaseError(`Erro na execução da consulta: ${error.message}`, error, sql, params);
1762
+ }
1763
+ }
1764
+
1765
+ // Caminho monitorado
1766
+ try {
1767
+ const result = await runMonitored({
1768
+ executor,
1769
+ originalFn: originalExecute,
1770
+ args: [sql, sanitizedParams],
1771
+ traceId,
1772
+ allowExplain: true,
1773
+ });
1774
+
1775
+ // mysql2 geralmente retorna [rows, fields]
1776
+ if (Array.isArray(result)) return result[0];
1777
+ return result;
1778
+ } catch (error) {
1779
+ throw new DatabaseError(`Erro na execução da consulta: ${error.message}`, error, sql, params);
1780
+ }
1781
+ }
1782
+
1783
+ /**
1784
+ * Busca todos os registros de uma tabela com paginação.
1785
+ * Atenção: SELECT * pode ser pesado em tabelas grandes.
1786
+ *
1787
+ * @param {string} tableName Nome da tabela (allow-list)
1788
+ * @param {number} [limit=100] Limite de linhas
1789
+ * @param {number} [offset=0] Offset
1790
+ * @returns {Promise<Array<any>>}
1791
+ */
1792
+ export async function findAll(tableName, limit = 100, offset = 0) {
1793
+ validateTableName(tableName);
1794
+ const safeLimit = parseInt(limit, 10);
1795
+ const safeOffset = parseInt(offset, 10);
1796
+
1797
+ if (isNaN(safeLimit) || isNaN(safeOffset)) {
1798
+ throw new Error('Limit e offset devem ser números válidos.');
1799
+ }
1800
+
1801
+ const sql = `SELECT * FROM ${mysql.escapeId(tableName)} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
1802
+ return executeQuery(sql);
1803
+ }
1804
+
1805
+ /**
1806
+ * Busca um registro por ID.
1807
+ * Requer coluna "id" na tabela.
1808
+ *
1809
+ * @param {string} tableName
1810
+ * @param {number|string} id
1811
+ * @returns {Promise<any|null>}
1812
+ */
1813
+ export async function findById(tableName, id) {
1814
+ validateTableName(tableName);
1815
+ const sql = `SELECT * FROM ${mysql.escapeId(tableName)} WHERE id = ?`;
1816
+ const results = await executeQuery(sql, [id]);
1817
+ return results[0] || null;
1818
+ }
1819
+
1820
+ /**
1821
+ * Busca registros por critérios simples de igualdade (AND).
1822
+ * - criteria: { coluna: valor }
1823
+ * - options: orderBy/orderDirection/limit/offset
1824
+ *
1825
+ * @param {string} tableName
1826
+ * @param {object} criteria
1827
+ * @param {object} [options]
1828
+ * @param {number} [options.limit]
1829
+ * @param {number} [options.offset]
1830
+ * @param {string} [options.orderBy]
1831
+ * @param {'ASC'|'DESC'} [options.orderDirection='ASC']
1832
+ * @returns {Promise<Array<any>>}
1833
+ */
1834
+ export async function findBy(tableName, criteria, options = {}) {
1835
+ validateTableName(tableName);
1836
+ const keys = Object.keys(criteria);
1837
+
1838
+ if (keys.length === 0) {
1839
+ return findAll(tableName, options.limit, options.offset);
1840
+ }
1841
+
1842
+ const whereClause = keys.map((key) => `${mysql.escapeId(key)} = ?`).join(' AND ');
1843
+ const params = Object.values(criteria);
1844
+
1845
+ let sql = `SELECT * FROM ${mysql.escapeId(tableName)} WHERE ${whereClause}`;
1846
+
1847
+ if (options.orderBy) {
1848
+ const direction = options.orderDirection?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
1849
+ sql += ` ORDER BY ${mysql.escapeId(options.orderBy)} ${direction}`;
1850
+ }
1851
+
1852
+ if (options.limit !== undefined) {
1853
+ sql += ` LIMIT ${parseInt(options.limit, 10)}`;
1854
+ }
1855
+
1856
+ if (options.offset !== undefined) {
1857
+ sql += ` OFFSET ${parseInt(options.offset, 10)}`;
1858
+ }
1859
+
1860
+ return executeQuery(sql, params);
1861
+ }
1862
+
1863
+ /**
1864
+ * Conta registros com filtro opcional.
1865
+ *
1866
+ * @param {string} tableName
1867
+ * @param {object} [criteria]
1868
+ * @returns {Promise<number>}
1869
+ */
1870
+ export async function count(tableName, criteria = {}) {
1871
+ validateTableName(tableName);
1872
+
1873
+ const keys = Object.keys(criteria);
1874
+ let sql = `SELECT COUNT(*) as count FROM ${mysql.escapeId(tableName)}`;
1875
+ let params = [];
1876
+
1877
+ if (keys.length > 0) {
1878
+ const whereClause = keys.map((key) => `${mysql.escapeId(key)} = ?`).join(' AND ');
1879
+ sql += ` WHERE ${whereClause}`;
1880
+ params = Object.values(criteria);
1881
+ }
1882
+
1883
+ const result = await executeQuery(sql, params);
1884
+ return result[0].count;
1885
+ }
1886
+
1887
+ /**
1888
+ * Cria um novo registro.
1889
+ * @param {string} tableName
1890
+ * @param {object} data
1891
+ * @returns {Promise<object>}
1892
+ */
1893
+ export async function create(tableName, data) {
1894
+ validateTableName(tableName);
1895
+
1896
+ const keys = Object.keys(data);
1897
+ if (keys.length === 0) {
1898
+ throw new Error('Não é possível criar um registro com dados vazios.');
1899
+ }
1900
+
1901
+ const values = Object.values(data);
1902
+ const sql = `INSERT INTO ${mysql.escapeId(tableName)} (${keys.map(mysql.escapeId).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`;
1903
+
1904
+ const result = await executeQuery(sql, values);
1905
+ return { id: result.insertId, ...data };
1906
+ }
1907
+
1908
+ /**
1909
+ * Cria um novo registro ignorando duplicidade (INSERT IGNORE).
1910
+ * @param {string} tableName
1911
+ * @param {object} data
1912
+ * @returns {Promise<object|null>} null se foi ignorado
1913
+ */
1914
+ export async function createIgnore(tableName, data) {
1915
+ validateTableName(tableName);
1916
+
1917
+ const keys = Object.keys(data);
1918
+ if (keys.length === 0) {
1919
+ throw new Error('Não é possível criar um registro com dados vazios.');
1920
+ }
1921
+
1922
+ const values = Object.values(data);
1923
+ const sql = `INSERT IGNORE INTO ${mysql.escapeId(tableName)} (${keys.map(mysql.escapeId).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`;
1924
+
1925
+ const result = await executeQuery(sql, values);
1926
+ if (!result.insertId) return null;
1927
+ return { id: result.insertId, ...data };
1928
+ }
1929
+
1930
+ /**
1931
+ * Insere múltiplos registros usando INSERT ... VALUES ?
1932
+ * Nota:
1933
+ * - assume que todos os records possuem as mesmas chaves (usa records[0])
1934
+ * - converte undefined -> null (mysql2 não aceita undefined)
1935
+ *
1936
+ * @param {string} tableName
1937
+ * @param {Array<object>} records
1938
+ * @returns {Promise<number>} Quantidade de linhas afetadas
1939
+ */
1940
+ export async function bulkInsert(tableName, records) {
1941
+ validateTableName(tableName);
1942
+ if (!records || records.length === 0) return 0;
1943
+
1944
+ const keys = Object.keys(records[0]);
1945
+ const values = records.map((r) => keys.map((k) => (r[k] === undefined ? null : r[k])));
1946
+ const sql = `INSERT INTO ${mysql.escapeId(tableName)} (${keys.map(mysql.escapeId).join(', ')}) VALUES ?`;
1947
+
1948
+ const [result] = await pool.query(sql, [values]);
1949
+ return result.affectedRows;
1950
+ }
1951
+
1952
+ /**
1953
+ * Atualiza um registro por ID.
1954
+ * @param {string} tableName
1955
+ * @param {number|string} id
1956
+ * @param {object} data
1957
+ * @returns {Promise<boolean>}
1958
+ */
1959
+ export async function update(tableName, id, data) {
1960
+ validateTableName(tableName);
1961
+
1962
+ const keys = Object.keys(data);
1963
+ if (keys.length === 0) {
1964
+ throw new Error('Não é possível atualizar um registro com dados vazios.');
1965
+ }
1966
+
1967
+ const sets = keys.map((key) => `${mysql.escapeId(key)} = ?`).join(', ');
1968
+ const sql = `UPDATE ${mysql.escapeId(tableName)} SET ${sets} WHERE id = ?`;
1969
+ const result = await executeQuery(sql, [...Object.values(data), id]);
1970
+
1971
+ return result.affectedRows > 0;
1972
+ }
1973
+
1974
+ /**
1975
+ * Remove um registro por ID.
1976
+ * @param {string} tableName
1977
+ * @param {number|string} id
1978
+ * @returns {Promise<boolean>}
1979
+ */
1980
+ export async function remove(tableName, id) {
1981
+ validateTableName(tableName);
1982
+ const sql = `DELETE FROM ${mysql.escapeId(tableName)} WHERE id = ?`;
1983
+ const result = await executeQuery(sql, [id]);
1984
+ return result.affectedRows > 0;
1985
+ }
1986
+
1987
+ /**
1988
+ * Insere ou atualiza (upsert) usando ON DUPLICATE KEY UPDATE.
1989
+ *
1990
+ * Regras:
1991
+ * - "data" precisa ter chaves para inserir
1992
+ * - se "data" só tiver "id", lança erro (não há o que atualizar)
1993
+ *
1994
+ * @param {string} tableName
1995
+ * @param {object} data
1996
+ * @returns {Promise<any>}
1997
+ */
1998
+ export async function upsert(tableName, data) {
1999
+ validateTableName(tableName);
2000
+
2001
+ const keys = Object.keys(data);
2002
+ if (keys.length === 0) {
2003
+ throw new Error('Não é possível fazer upsert com dados vazios.');
2004
+ }
2005
+
2006
+ const updateData = { ...data };
2007
+ if (updateData.id) delete updateData.id;
2008
+
2009
+ if (Object.keys(updateData).length === 0) {
2010
+ throw new Error('Não é possível fazer upsert apenas com id. Informe campos adicionais para atualizar.');
2011
+ }
2012
+
2013
+ const insertKeys = keys.map(mysql.escapeId).join(', ');
2014
+ const insertPlaceholders = keys.map(() => '?').join(', ');
2015
+ const updateSets = Object.keys(updateData)
2016
+ .map((key) => `${mysql.escapeId(key)} = ?`)
2017
+ .join(', ');
2018
+
2019
+ const sql = `INSERT INTO ${mysql.escapeId(tableName)} (${insertKeys})
2020
+ VALUES (${insertPlaceholders})
2021
+ ON DUPLICATE KEY UPDATE ${updateSets}`;
2022
+
2023
+ const params = [...Object.values(data), ...Object.values(updateData)];
2024
+ return executeQuery(sql, params);
2025
+ }
2026
+
2027
+ /**
2028
+ * Executa operações dentro de uma transação.
2029
+ * Uso esperado:
2030
+ * await withTransaction(async (conn) => {
2031
+ * await executeQuery(sql1, p1, conn)
2032
+ * await executeQuery(sql2, p2, conn)
2033
+ * })
2034
+ *
2035
+ * @param {(connection: import('mysql2/promise').PoolConnection) => Promise<any>} callback
2036
+ * @returns {Promise<any>}
2037
+ */
2038
+ async function withTransaction(callback) {
2039
+ const connection = await pool.getConnection();
2040
+ try {
2041
+ await connection.beginTransaction();
2042
+ const result = await callback(connection);
2043
+ await connection.commit();
2044
+ return result;
2045
+ } catch (err) {
2046
+ await connection.rollback();
2047
+ logger.error('Transação revertida devido a um erro:', err);
2048
+ throw err;
2049
+ } finally {
2050
+ connection.release();
2051
+ }
2052
+ }