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