From bbaa2cb1a4fe36cd9c5aa5dd18c400dc57d22ee2 Mon Sep 17 00:00:00 2001 From: mrkmntal Date: Fri, 17 Apr 2026 11:44:16 -0400 Subject: [PATCH] Add MySQL2 library as dependency for the hazard history, rewrite to use mysql for data --- README.md | 47 +++- index.mjs | 7 + package-lock.json | 106 ++++++++- package.json | 3 +- server/scripts/modules/hazards.mjs | 2 +- src/hazard-history.mjs | 343 +++++++++-------------------- src/mysql.mjs | 66 ++++++ 7 files changed, 336 insertions(+), 238 deletions(-) create mode 100644 src/mysql.mjs diff --git a/README.md b/README.md index 643127a..47da745 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,48 @@ The `Ground View` screen requires a Windy Webcams API key. Create a file named ` You can obtain a free API key from [Windy Webcams API](https://api.windy.com/webcams). +## MySQL + +`Hazard List` is now backed by MySQL in server mode. The UI only shows the latest 7 hazards, but the database retains full history. + +Set these environment variables before starting the app: + +```text +WS4KP_MYSQL_HOST=127.0.0.1 +WS4KP_MYSQL_PORT=3306 +WS4KP_MYSQL_SOCKET_PATH=/var/run/mysql/mysql.sock +WS4KP_MYSQL_USER=root +WS4KP_MYSQL_PASSWORD=your-password +WS4KP_MYSQL_DATABASE=ws4kp_linhanced +``` + +If your local MariaDB/MySQL instance is socket-only, set `WS4KP_MYSQL_SOCKET_PATH` and omit `WS4KP_MYSQL_HOST` / `WS4KP_MYSQL_PORT`. + +Create the required table: + +```sql +CREATE TABLE hazard_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + location_label VARCHAR(255) NOT NULL, + location_key VARCHAR(128) NOT NULL, + hazard_type VARCHAR(128) NOT NULL, + source VARCHAR(64) NOT NULL, + severity VARCHAR(64) DEFAULT NULL, + latest_hazard_id VARCHAR(255) DEFAULT NULL, + encountered_at DATETIME NOT NULL, + last_seen_at DATETIME NOT NULL, + ongoing TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + UNIQUE KEY uq_logical_hazard (location_key, hazard_type, source), + KEY idx_last_seen_at (last_seen_at), + KEY idx_location_key (location_key), + KEY idx_ongoing (ongoing) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + ## Running Modes This fork supports two main runtime styles. @@ -137,6 +179,7 @@ This mode includes: * Express server entry point * proxying and caching for weather/map requests * Fastfetch-backed Server Observations +* MySQL-backed Hazard List history * better shared performance when multiple clients use the same instance ### Static Mode @@ -154,9 +197,9 @@ Or upload the generated `dist/` directory to your web server after running: npm run build ``` -The static build has been adjusted so frontend-generated paths no longer assume deployment at `/`, which makes subdirectory hosting more practical. **Also, features that require a backend server like the on-disk cache, Fastfetch-backed Server Observations, LWN Linux News, and `Ground View` will not work when running the static build by itself.** +The static build has been adjusted so frontend-generated paths no longer assume deployment at `/`, which makes subdirectory hosting more practical. **Also, features that require a backend server like the on-disk cache, Fastfetch-backed Server Observations, LWN Linux News, `Ground View`, and `Hazard List` will not work when running the static build by itself.** -The public demo at [https://mentalnet.xyz/ws4kp-linhanced-demo/](https://mentalnet.xyz/ws4kp-linhanced-demo/) is intentionally served as a static build, so the `Linux News`, `Server Observations`, and `Ground View` screens will not work there. +The public demo at [https://mentalnet.xyz/ws4kp-linhanced-demo/](https://mentalnet.xyz/ws4kp-linhanced-demo/) is intentionally served as a static build, so the `Linux News`, `Server Observations`, `Ground View`, and `Hazard List` screens will not work there. ## International Support diff --git a/index.mjs b/index.mjs index 929430b..6f8b398 100644 --- a/index.mjs +++ b/index.mjs @@ -21,6 +21,7 @@ import cache from './proxy/cache.mjs'; import devTools from './src/com.chrome.devtools.mjs'; import { discoverThemes } from './src/theme-discovery.mjs'; import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs'; +import { checkHazardHistoryTable } from './src/mysql.mjs'; import { getHistory, updateHistory } from './src/hazard-history.mjs'; const execAsync = promisify(exec); @@ -203,6 +204,12 @@ const staticOptions = { // Weather.gov API proxy (catch-all for any Weather.gov API endpoint) // Skip setting up routes for the caching proxy server in static mode if (!process.env?.STATIC) { + try { + await checkHazardHistoryTable(); + } catch (error) { + console.error(error.message); + } + // Server info endpoint for fastfetch output (must be before /api/ weather proxy) app.get('/api/server-info', async (req, res) => { try { diff --git a/package-lock.json b/package-lock.json index b02b446..2bbdc68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "dotenv": "^17.0.1", "ejs": "^5.0.1", - "express": "^5.1.0" + "express": "^5.1.0", + "mysql2": "^3.22.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", @@ -3071,6 +3072,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/b4a": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", @@ -3829,6 +3839,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5094,6 +5113,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -6421,6 +6449,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6861,6 +6895,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -6871,6 +6911,21 @@ "tslib": "^2.0.3" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -7037,6 +7092,40 @@ "node": ">= 10.13.0" } }, + "node_modules/mysql2": { + "version": "3.22.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.1.tgz", + "integrity": "sha512-48+9UXehKyxxiP2pqCxUq+MSFvX+v41jwsSpFDQO/jAoFuAELutBGJUhWJnDbe82/OBlIhSBMC82WeonmznT/Q==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8379,6 +8468,21 @@ "node": ">= 10.13.0" } }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index cb27aa5..0ddca89 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "dependencies": { "dotenv": "^17.0.1", "ejs": "^5.0.1", - "express": "^5.1.0" + "express": "^5.1.0", + "mysql2": "^3.22.1" } } diff --git a/server/scripts/modules/hazards.mjs b/server/scripts/modules/hazards.mjs index e334041..3fb5a38 100644 --- a/server/scripts/modules/hazards.mjs +++ b/server/scripts/modules/hazards.mjs @@ -285,7 +285,7 @@ class Hazards extends WeatherDisplay { id: hazard.id, hazardType: hazard.properties?.event || 'Unknown', severity: hazard.properties?.severity || 'Unknown', - source: hazard.properties?.senderName?.includes('NOAA') ? 'noaa' : 'derived', + source: String(hazard.id || '').startsWith('derived-') ? 'derived' : 'noaa', })); // Send to backend diff --git a/src/hazard-history.mjs b/src/hazard-history.mjs index b99858f..9545d17 100644 --- a/src/hazard-history.mjs +++ b/src/hazard-history.mjs @@ -1,141 +1,24 @@ -/** - * Hazard History persistence module - * Tracks the last 7 hazard alerts encountered by this server instance - */ +import { getPool } from './mysql.mjs'; -import { readFile, writeFile, mkdir } from 'fs/promises'; -import path from 'path'; - -const HISTORY_FILE = path.resolve('./data/hazard-history.json'); const MAX_HISTORY_ENTRIES = 7; -/** - * Ensure the cache directory exists - */ -const ensureCacheDir = async () => { - const cacheDir = path.dirname(HISTORY_FILE); - try { - await mkdir(cacheDir, { recursive: true }); - } catch (error) { - // Directory may already exist - } +const toIsoString = (value) => { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); }; -/** - * Load hazard history from disk - * @returns {Array} Array of hazard history entries - */ -const loadHistory = async () => { - try { - await ensureCacheDir(); - const data = await readFile(HISTORY_FILE, 'utf8'); - const parsed = JSON.parse(data); - return Array.isArray(parsed) ? parsed : []; - } catch (error) { - // File doesn't exist or is corrupted, return empty array - return []; - } -}; - -/** - * Save hazard history to disk - * @param {Array} history - Array of hazard history entries - */ -const saveHistory = async (history) => { - try { - await ensureCacheDir(); - await writeFile(HISTORY_FILE, JSON.stringify(history, null, '\t')); - } catch (error) { - console.error('Failed to save hazard history:', error.message); - } -}; - -const isCoordinateLocationKey = (value) => /^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/.test(value ?? ''); - -/** - * Generate a stable identity for a hazard entry. - * This intentionally ignores upstream alert ids so alert revisions - * continue updating the same logical history row. - * @param {string} locationKey - Stable location key - * @param {string} hazardType - Hazard/event name - * @param {string} source - Hazard source - * @returns {string} Stable identity key - */ -const generateKey = (locationKey, hazardType, source) => `${locationKey}::${hazardType}::${source}`; - -const normalizeTimestamp = (value, fallback) => { - const date = new Date(value); - return Number.isNaN(date.getTime()) ? fallback : date.toISOString(); -}; - -const isSameLogicalHazard = (left, right) => left.location === right.location - && left.hazardType === right.hazardType - && left.source === right.source; - -const isSameRequestedLocation = (entry, location, locationKey) => { - if (locationKey && entry.locationKey === locationKey) return true; - return entry.location === location; -}; - -const upgradeEntryLocationKey = (entry, location, locationKey) => { - if (!locationKey || entry.locationKey === locationKey) return entry; - return { - ...entry, - location, - locationKey, - key: generateKey(locationKey, entry.hazardType, entry.source), - }; -}; - -const mergeEntries = (existing, incoming) => { - const existingEncountered = normalizeTimestamp(existing.encounteredAt, incoming.encounteredAt); - const incomingEncountered = normalizeTimestamp(incoming.encounteredAt, existing.encounteredAt); - const existingLastSeen = normalizeTimestamp(existing.lastSeenAt, incoming.lastSeenAt); - const incomingLastSeen = normalizeTimestamp(incoming.lastSeenAt, existing.lastSeenAt); - const keepIncomingLocationKey = isCoordinateLocationKey(incoming.locationKey) && !isCoordinateLocationKey(existing.locationKey); - const latestHazardId = new Date(incomingLastSeen) >= new Date(existingLastSeen) - ? (incoming.latestHazardId ?? existing.latestHazardId) - : (existing.latestHazardId ?? incoming.latestHazardId); - - return { - ...existing, - location: keepIncomingLocationKey ? incoming.location : (existing.location || incoming.location), - locationKey: keepIncomingLocationKey ? incoming.locationKey : (existing.locationKey || incoming.locationKey), - key: keepIncomingLocationKey ? incoming.key : existing.key, - encounteredAt: new Date(existingEncountered) <= new Date(incomingEncountered) ? existingEncountered : incomingEncountered, - lastSeenAt: new Date(existingLastSeen) >= new Date(incomingLastSeen) ? existingLastSeen : incomingLastSeen, - ongoing: Boolean(existing.ongoing || incoming.ongoing), - severity: incoming.severity || existing.severity, - source: incoming.source || existing.source, - latestHazardId, - }; -}; - -const normalizeHistory = (history = []) => { - const normalized = []; - - for (const rawEntry of history) { - if (!rawEntry?.hazardType || !rawEntry?.source) continue; - const locationKey = rawEntry.locationKey || rawEntry.location; - const entry = { - ...rawEntry, - locationKey, - key: generateKey(locationKey, rawEntry.hazardType, rawEntry.source), - encounteredAt: normalizeTimestamp(rawEntry.encounteredAt, new Date().toISOString()), - lastSeenAt: normalizeTimestamp(rawEntry.lastSeenAt ?? rawEntry.encounteredAt, new Date().toISOString()), - latestHazardId: rawEntry.latestHazardId ?? rawEntry.hazardId ?? rawEntry.id ?? rawEntry.key, - }; - - const existingIndex = normalized.findIndex((candidate) => candidate.key === entry.key || isSameLogicalHazard(candidate, entry)); - if (existingIndex >= 0) { - normalized[existingIndex] = mergeEntries(normalized[existingIndex], entry); - } else { - normalized.push(entry); - } - } - - return normalized; -}; +const mapRowToHistoryEntry = (row) => ({ + location: row.location_label, + locationKey: row.location_key, + hazardType: row.hazard_type, + source: row.source, + severity: row.severity, + latestHazardId: row.latest_hazard_id, + encounteredAt: toIsoString(row.encountered_at), + lastSeenAt: toIsoString(row.last_seen_at), + ongoing: Boolean(row.ongoing), +}); /** * Format location label from weather parameters @@ -147,120 +30,114 @@ const normalizeHistory = (history = []) => { */ const formatLocation = (city, state, country, countryCode) => { const cleanCity = city?.trim() || 'Unknown'; - - // US locations: "City, State" + if (countryCode === 'US' || countryCode === 'USA') { const cleanState = state?.trim(); return cleanState ? `${cleanCity}, ${cleanState}` : cleanCity; } - - // Non-US locations: "City, Country" + const cleanCountry = country?.trim(); return cleanCountry ? `${cleanCity}, ${cleanCountry}` : cleanCity; }; -/** - * Update hazard history with current active hazards for a location - * @param {Object} payload - Request payload - * @param {string} payload.location - Formatted location label (for display) - * @param {string} payload.locationKey - Stable location key from lat/lon (for matching) - * @param {Array} payload.hazards - Array of active hazards - * @returns {Array} Updated history - */ -const updateHistory = async (payload) => { - const { location, locationKey, hazards = [] } = payload; - - // Load existing history - let history = normalizeHistory(await loadHistory()); - const now = new Date().toISOString(); - - // Use locationKey for matching if provided, fall back to location for backward compatibility - const matchKey = locationKey || location; - - // Create a set of active hazard identities for this location - const activeKeys = new Set(hazards.map((hazard) => generateKey(matchKey, hazard.hazardType, hazard.source))); - - // Mark previously ongoing hazards for this location as ended if no longer active - history = history.map((entry) => { - if (!isSameRequestedLocation(entry, location, locationKey)) return entry; +const getHistory = async () => { + const [rows] = await getPool().query( + `SELECT + location_label, + location_key, + hazard_type, + source, + severity, + latest_hazard_id, + encountered_at, + last_seen_at, + ongoing + FROM hazard_history + ORDER BY last_seen_at DESC + LIMIT ?`, + [MAX_HISTORY_ENTRIES], + ); - const upgradedEntry = upgradeEntryLocationKey(entry, location, locationKey); - - // If this entry is ongoing but not in the current active set, mark it as ended - if (upgradedEntry.ongoing && !activeKeys.has(upgradedEntry.key)) { - return { - ...upgradedEntry, - ongoing: false, - lastSeenAt: now, - }; - } - return upgradedEntry; - }); - - // Add or update active hazards - hazards.forEach((hazard) => { - const key = generateKey(matchKey, hazard.hazardType, hazard.source); - const existingIndex = history.findIndex((entry) => entry.key === key); - - if (existingIndex >= 0) { - // Update existing entry - history[existingIndex] = { - ...history[existingIndex], - lastSeenAt: now, - ongoing: true, - // Update severity if it changed - severity: hazard.severity || history[existingIndex].severity, - latestHazardId: hazard.id, - }; - } else { - // Create new entry - history.push({ - key, - location, - locationKey: matchKey, - hazardType: hazard.hazardType, - encounteredAt: now, - lastSeenAt: now, - ongoing: true, - severity: hazard.severity, - source: hazard.source, - latestHazardId: hazard.id, - }); - } - }); - - history = normalizeHistory(history); - - // Sort by lastSeenAt descending (newest first) - history.sort((a, b) => new Date(b.lastSeenAt) - new Date(a.lastSeenAt)); - - // Trim to max entries - if (history.length > MAX_HISTORY_ENTRIES) { - history = history.slice(0, MAX_HISTORY_ENTRIES); - } - - // Save updated history - await saveHistory(history); - - return history; + return rows.map(mapRowToHistoryEntry); }; -/** - * Get current hazard history - * @returns {Array} Current history entries - */ -const getHistory = async () => { - const history = normalizeHistory(await loadHistory()); - // Ensure sorted by lastSeenAt descending - return history.sort((a, b) => new Date(b.lastSeenAt) - new Date(a.lastSeenAt)); +const updateHistory = async (payload) => { + const { location, locationKey, hazards = [] } = payload; + const validHazards = hazards.filter((hazard) => hazard?.hazardType && hazard?.source); + const pool = getPool(); + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + if (validHazards.length === 0) { + await connection.execute( + `UPDATE hazard_history + SET ongoing = 0, + last_seen_at = UTC_TIMESTAMP() + WHERE location_key = ? + AND ongoing = 1`, + [locationKey], + ); + } else { + const keepClauses = validHazards.map(() => '(hazard_type = ? AND source = ?)').join(' OR '); + const keepParams = validHazards.flatMap((hazard) => [hazard.hazardType, hazard.source]); + + await connection.execute( + `UPDATE hazard_history + SET ongoing = 0, + last_seen_at = UTC_TIMESTAMP() + WHERE location_key = ? + AND ongoing = 1 + AND NOT (${keepClauses})`, + [locationKey, ...keepParams], + ); + } + + for (const hazard of validHazards) { + await connection.execute( + `INSERT INTO hazard_history ( + location_label, + location_key, + hazard_type, + source, + severity, + latest_hazard_id, + encountered_at, + last_seen_at, + ongoing + ) VALUES (?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP(), 1) + ON DUPLICATE KEY UPDATE + location_label = VALUES(location_label), + severity = VALUES(severity), + latest_hazard_id = VALUES(latest_hazard_id), + last_seen_at = UTC_TIMESTAMP(), + ongoing = 1`, + [ + location, + locationKey, + hazard.hazardType, + hazard.source, + hazard.severity ?? null, + hazard.id ?? null, + ], + ); + } + + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + + return getHistory(); }; export { - loadHistory, - saveHistory, - updateHistory, - getHistory, formatLocation, - generateKey, + getHistory, MAX_HISTORY_ENTRIES, + updateHistory, }; diff --git a/src/mysql.mjs b/src/mysql.mjs new file mode 100644 index 0000000..0110164 --- /dev/null +++ b/src/mysql.mjs @@ -0,0 +1,66 @@ +import mysql from 'mysql2/promise'; + +let pool; + +const getConfig = () => { + const { + WS4KP_MYSQL_HOST = '127.0.0.1', + WS4KP_MYSQL_PORT = '3306', + WS4KP_MYSQL_USER, + WS4KP_MYSQL_PASSWORD, + WS4KP_MYSQL_DATABASE, + WS4KP_MYSQL_SOCKET_PATH, + } = process.env; + + if (!WS4KP_MYSQL_USER || !WS4KP_MYSQL_PASSWORD || !WS4KP_MYSQL_DATABASE) { + throw new Error('Missing MySQL configuration. Set WS4KP_MYSQL_USER, WS4KP_MYSQL_PASSWORD, and WS4KP_MYSQL_DATABASE.'); + } + + const config = { + user: WS4KP_MYSQL_USER, + password: WS4KP_MYSQL_PASSWORD, + database: WS4KP_MYSQL_DATABASE, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + }; + + if (WS4KP_MYSQL_SOCKET_PATH) { + config.socketPath = WS4KP_MYSQL_SOCKET_PATH; + } else { + config.host = WS4KP_MYSQL_HOST; + config.port = Number(WS4KP_MYSQL_PORT); + } + + return config; +}; + +const getPool = () => { + if (!pool) { + pool = mysql.createPool(getConfig()); + } + return pool; +}; + +const checkHazardHistoryTable = async () => { + const config = getConfig(); + const [rows] = await getPool().query( + `SELECT 1 + FROM information_schema.tables + WHERE table_schema = ? + AND table_name = 'hazard_history' + LIMIT 1`, + [config.database], + ); + + if (rows.length === 0) { + throw new Error(`Hazard history database table 'hazard_history' is missing in database '${config.database}'. Run the documented CREATE TABLE statement before using Hazard List.`); + } + + return true; +}; + +export { + checkHazardHistoryTable, + getPool, +};