"use strict"; function cloneJson(value) { return JSON.parse(JSON.stringify(value)); } function toBoolean(value, fallback = true) { if (value === undefined || value === null) return fallback; return value !== false && value !== 0 && value !== "0"; } function toSqlDateTime(isoString) { const date = isoString ? new Date(isoString) : new Date(); if (Number.isNaN(date.getTime())) { throw new Error(`Invalid date: ${isoString}`); } const yyyy = String(date.getUTCFullYear()); const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); const dd = String(date.getUTCDate()).padStart(2, "0"); const hh = String(date.getUTCHours()).padStart(2, "0"); const mi = String(date.getUTCMinutes()).padStart(2, "0"); const ss = String(date.getUTCSeconds()).padStart(2, "0"); const mmm = String(date.getUTCMilliseconds()).padStart(3, "0"); return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}.${mmm}`; } function fromSqlDateTime(value) { if (!value) return null; const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return null; return date.toISOString(); } function parseJsonColumn(value, fallback) { if (value === null || value === undefined) { return fallback; } if (typeof value === "object") { return value; } try { return JSON.parse(value); } catch (_error) { return fallback; } } class InMemoryDashboardStore { constructor() { this.watches = new Map(); this.events = []; this.globalControls = { crawlingEnabled: true, alertsEnabled: true, }; } async init() {} async close() {} async listWatches() { return Array.from(this.watches.values()) .map((watch) => cloneJson(watch)) .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))); } async getWatch(watchId) { const watch = this.watches.get(watchId); return watch ? cloneJson(watch) : null; } async saveWatch(watch) { const next = cloneJson(watch); this.watches.set(next.id, next); return next; } async deleteWatch(watchId) { return this.watches.delete(watchId); } async savePollResult(watchId, pollResult) { const watch = this.watches.get(watchId); if (!watch) return; let touched = false; if (pollResult && pollResult.snapshot) { watch.lastSnapshot = cloneJson(pollResult.snapshot); watch.lastError = null; touched = true; } if (pollResult && pollResult.error) { watch.lastError = cloneJson(pollResult.error); touched = true; } if (touched) { watch.updatedAt = new Date().toISOString(); } } async saveEvent(event) { const stored = { id: `${Date.now()}-${Math.floor(Math.random() * 100000)}`, watchId: event.watchId, eventType: event.eventType, payload: cloneJson(event.payload), observedAt: event.observedAt, createdAt: new Date().toISOString(), }; this.events.unshift(stored); if (this.events.length > 1000) { this.events.length = 1000; } return cloneJson(stored); } async listEvents(limit = 50) { const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200)); return this.events.slice(0, safeLimit).map((event) => cloneJson(event)); } async getGlobalControls() { return cloneJson(this.globalControls); } async setGlobalControls(patch = {}) { if (Object.prototype.hasOwnProperty.call(patch, "crawlingEnabled")) { this.globalControls.crawlingEnabled = toBoolean( patch.crawlingEnabled, this.globalControls.crawlingEnabled ); } if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) { this.globalControls.alertsEnabled = toBoolean( patch.alertsEnabled, this.globalControls.alertsEnabled ); } return cloneJson(this.globalControls); } } class MySqlDashboardStore { constructor(pool) { this.pool = pool; } static async create(options = {}) { let mysql; try { // Optional dependency: only loaded when MySQL mode is used. mysql = require("mysql2/promise"); } catch (_error) { throw new Error( "mysql2 패키지가 필요합니다. `npm install mysql2` 후 다시 실행하세요." ); } const pool = mysql.createPool({ uri: options.uri, host: options.host, port: options.port, user: options.user, password: options.password, database: options.database, waitForConnections: true, connectionLimit: Number(options.connectionLimit) || 5, queueLimit: 0, charset: "utf8mb4", timezone: "Z", }); const store = new MySqlDashboardStore(pool); await store.init(); return store; } async init() { await this.pool.query(` CREATE TABLE IF NOT EXISTS watches ( id VARCHAR(64) NOT NULL, raw_input TEXT NOT NULL, parsed_params JSON NOT NULL, alert_rules JSON NOT NULL, polling_enabled TINYINT(1) NOT NULL DEFAULT 1, alerts_enabled TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, last_snapshot JSON NULL, last_error JSON NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; `); await this.pool.query(` CREATE TABLE IF NOT EXISTS watch_events ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, watch_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, payload JSON NOT NULL, observed_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL, PRIMARY KEY (id), INDEX idx_watch_events_watch_id (watch_id, observed_at DESC) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; `); await this.pool.query(` CREATE TABLE IF NOT EXISTS app_settings ( setting_key VARCHAR(64) NOT NULL, setting_value JSON NOT NULL, updated_at DATETIME(3) NOT NULL, PRIMARY KEY (setting_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; `); } async close() { await this.pool.end(); } async listWatches() { const [rows] = await this.pool.query(` SELECT id, raw_input, parsed_params, alert_rules, polling_enabled, alerts_enabled, created_at, updated_at, last_snapshot, last_error FROM watches ORDER BY created_at DESC `); return rows.map((row) => ({ id: row.id, rawInput: row.raw_input, searchParams: parseJsonColumn(row.parsed_params, {}), alertRules: parseJsonColumn(row.alert_rules, {}), pollingEnabled: toBoolean(row.polling_enabled, true), alertsEnabled: toBoolean(row.alerts_enabled, true), createdAt: fromSqlDateTime(row.created_at), updatedAt: fromSqlDateTime(row.updated_at), lastSnapshot: parseJsonColumn(row.last_snapshot, null), lastError: parseJsonColumn(row.last_error, null), })); } async getWatch(watchId) { const [rows] = await this.pool.query( ` SELECT id, raw_input, parsed_params, alert_rules, polling_enabled, alerts_enabled, created_at, updated_at, last_snapshot, last_error FROM watches WHERE id = ? LIMIT 1 `, [watchId] ); if (rows.length === 0) return null; const row = rows[0]; return { id: row.id, rawInput: row.raw_input, searchParams: parseJsonColumn(row.parsed_params, {}), alertRules: parseJsonColumn(row.alert_rules, {}), pollingEnabled: toBoolean(row.polling_enabled, true), alertsEnabled: toBoolean(row.alerts_enabled, true), createdAt: fromSqlDateTime(row.created_at), updatedAt: fromSqlDateTime(row.updated_at), lastSnapshot: parseJsonColumn(row.last_snapshot, null), lastError: parseJsonColumn(row.last_error, null), }; } async saveWatch(watch) { const createdAt = watch.createdAt || new Date().toISOString(); const updatedAt = watch.updatedAt || createdAt; await this.pool.query( ` INSERT INTO watches ( id, raw_input, parsed_params, alert_rules, polling_enabled, alerts_enabled, created_at, updated_at, last_snapshot, last_error ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE raw_input = VALUES(raw_input), parsed_params = VALUES(parsed_params), alert_rules = VALUES(alert_rules), polling_enabled = VALUES(polling_enabled), alerts_enabled = VALUES(alerts_enabled), updated_at = VALUES(updated_at), last_snapshot = VALUES(last_snapshot), last_error = VALUES(last_error) `, [ watch.id, watch.rawInput || "", JSON.stringify(watch.searchParams || {}), JSON.stringify(watch.alertRules || {}), watch.pollingEnabled === false ? 0 : 1, watch.alertsEnabled === false ? 0 : 1, toSqlDateTime(createdAt), toSqlDateTime(updatedAt), watch.lastSnapshot ? JSON.stringify(watch.lastSnapshot) : null, watch.lastError ? JSON.stringify(watch.lastError) : null, ] ); return this.getWatch(watch.id); } async deleteWatch(watchId) { const [result] = await this.pool.query(`DELETE FROM watches WHERE id = ?`, [watchId]); return result.affectedRows > 0; } async savePollResult(watchId, pollResult) { const updates = []; const params = []; const nowIso = new Date().toISOString(); if (pollResult && pollResult.snapshot) { updates.push("last_snapshot = ?"); params.push(JSON.stringify(pollResult.snapshot)); updates.push("last_error = NULL"); } if (pollResult && pollResult.error) { updates.push("last_error = ?"); params.push(JSON.stringify(pollResult.error)); } if (updates.length === 0) { return; } updates.push("updated_at = ?"); params.push(toSqlDateTime(nowIso)); params.push(watchId); await this.pool.query( ` UPDATE watches SET ${updates.join(", ")} WHERE id = ? `, params ); } async saveEvent(event) { const createdAt = new Date().toISOString(); const observedAt = event.observedAt || createdAt; const [result] = await this.pool.query( ` INSERT INTO watch_events (watch_id, event_type, payload, observed_at, created_at) VALUES (?, ?, ?, ?, ?) `, [ event.watchId, event.eventType || "unknown", JSON.stringify(event.payload || {}), toSqlDateTime(observedAt), toSqlDateTime(createdAt), ] ); return { id: String(result.insertId), watchId: event.watchId, eventType: event.eventType, payload: cloneJson(event.payload || {}), observedAt, createdAt, }; } async listEvents(limit = 50) { const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200)); const [rows] = await this.pool.query( ` SELECT id, watch_id, event_type, payload, observed_at, created_at FROM watch_events ORDER BY created_at DESC LIMIT ? `, [safeLimit] ); return rows.map((row) => ({ id: String(row.id), watchId: row.watch_id, eventType: row.event_type, payload: parseJsonColumn(row.payload, {}), observedAt: fromSqlDateTime(row.observed_at), createdAt: fromSqlDateTime(row.created_at), })); } async getGlobalControls() { const [rows] = await this.pool.query( ` SELECT setting_value FROM app_settings WHERE setting_key = 'global_controls' LIMIT 1 ` ); if (rows.length === 0) { return { crawlingEnabled: true, alertsEnabled: true, }; } const setting = parseJsonColumn(rows[0].setting_value, {}); return { crawlingEnabled: toBoolean(setting.crawlingEnabled, true), alertsEnabled: toBoolean(setting.alertsEnabled, true), }; } async setGlobalControls(patch = {}) { const next = { ...(await this.getGlobalControls()), }; if (Object.prototype.hasOwnProperty.call(patch, "crawlingEnabled")) { next.crawlingEnabled = toBoolean(patch.crawlingEnabled, next.crawlingEnabled); } if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) { next.alertsEnabled = toBoolean(patch.alertsEnabled, next.alertsEnabled); } await this.pool.query( ` INSERT INTO app_settings (setting_key, setting_value, updated_at) VALUES ('global_controls', ?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = VALUES(updated_at) `, [JSON.stringify(next), toSqlDateTime(new Date().toISOString())] ); return next; } } function parsePort(rawPort, fallback) { const n = Number(rawPort); if (!Number.isInteger(n) || n <= 0) return fallback; return n; } async function createDashboardStore(options = {}) { const modeRaw = options.mode || process.env.DASHBOARD_DB || ""; const mode = typeof modeRaw === "string" ? modeRaw.trim().toLowerCase() : ""; const isStrictMySqlMode = mode === "mysql"; const prefersMySql = isStrictMySqlMode || Boolean(process.env.MYSQL_URL) || Boolean(process.env.MYSQL_HOST && process.env.MYSQL_USER && process.env.MYSQL_DATABASE); if (!prefersMySql || mode === "memory") { const store = new InMemoryDashboardStore(); await store.init(); return { engine: "memory", store, warning: null, }; } const mysqlOptions = { uri: options.mysqlUrl || process.env.MYSQL_URL, host: options.mysqlHost || process.env.MYSQL_HOST, port: parsePort(options.mysqlPort || process.env.MYSQL_PORT, 3306), user: options.mysqlUser || process.env.MYSQL_USER, password: options.mysqlPassword || process.env.MYSQL_PASSWORD, database: options.mysqlDatabase || process.env.MYSQL_DATABASE, connectionLimit: options.mysqlConnectionLimit || process.env.MYSQL_CONNECTION_LIMIT, }; if (!mysqlOptions.uri && (!mysqlOptions.host || !mysqlOptions.user || !mysqlOptions.database)) { if (isStrictMySqlMode) { throw new Error( "MySQL 연결 정보가 필요합니다. MYSQL_URL 또는 MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하세요." ); } const fallbackStore = new InMemoryDashboardStore(); await fallbackStore.init(); return { engine: "memory", store: fallbackStore, warning: "MySQL 환경변수가 없어 메모리 저장소를 사용합니다. MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하면 MySQL로 전환됩니다.", }; } try { const store = await MySqlDashboardStore.create(mysqlOptions); return { engine: "mysql", store, warning: null, }; } catch (error) { if (isStrictMySqlMode) { throw error; } const fallbackStore = new InMemoryDashboardStore(); await fallbackStore.init(); return { engine: "memory", store: fallbackStore, warning: `MySQL 초기화 실패로 메모리 저장소를 사용합니다: ${error.message}`, }; } } module.exports = { InMemoryDashboardStore, MySqlDashboardStore, createDashboardStore, };