@omnizap-system/omnizap 2.5.12

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