Add MySQL2 library as dependency for the hazard history, rewrite to use mysql for data

This commit is contained in:
mrkmntal 2026-04-17 11:44:16 -04:00
commit bbaa2cb1a4
7 changed files with 336 additions and 238 deletions

View file

@ -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). 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 ## Running Modes
This fork supports two main runtime styles. This fork supports two main runtime styles.
@ -137,6 +179,7 @@ This mode includes:
* Express server entry point * Express server entry point
* proxying and caching for weather/map requests * proxying and caching for weather/map requests
* Fastfetch-backed Server Observations * Fastfetch-backed Server Observations
* MySQL-backed Hazard List history
* better shared performance when multiple clients use the same instance * better shared performance when multiple clients use the same instance
### Static Mode ### Static Mode
@ -154,9 +197,9 @@ Or upload the generated `dist/` directory to your web server after running:
npm run build 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 ## International Support

View file

@ -21,6 +21,7 @@ import cache from './proxy/cache.mjs';
import devTools from './src/com.chrome.devtools.mjs'; import devTools from './src/com.chrome.devtools.mjs';
import { discoverThemes } from './src/theme-discovery.mjs'; import { discoverThemes } from './src/theme-discovery.mjs';
import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs'; import { findNearestWindyWebcam, loadWindyApiKey } from './src/windy-webcams.mjs';
import { checkHazardHistoryTable } from './src/mysql.mjs';
import { getHistory, updateHistory } from './src/hazard-history.mjs'; import { getHistory, updateHistory } from './src/hazard-history.mjs';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@ -203,6 +204,12 @@ const staticOptions = {
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint) // Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
// Skip setting up routes for the caching proxy server in static mode // Skip setting up routes for the caching proxy server in static mode
if (!process.env?.STATIC) { 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) // Server info endpoint for fastfetch output (must be before /api/ weather proxy)
app.get('/api/server-info', async (req, res) => { app.get('/api/server-info', async (req, res) => {
try { try {

106
package-lock.json generated
View file

@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"ejs": "^5.0.1", "ejs": "^5.0.1",
"express": "^5.1.0" "express": "^5.1.0",
"mysql2": "^3.22.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
@ -3071,6 +3072,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/b4a": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
@ -3829,6 +3839,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -5094,6 +5113,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/generator-function": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@ -6421,6 +6449,12 @@
"node": ">=0.10.0" "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": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@ -6861,6 +6895,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lower-case": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@ -6871,6 +6911,21 @@
"tslib": "^2.0.3" "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": { "node_modules/luxon": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
@ -7037,6 +7092,40 @@
"node": ">= 10.13.0" "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": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -8379,6 +8468,21 @@
"node": ">= 10.13.0" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View file

@ -56,6 +56,7 @@
"dependencies": { "dependencies": {
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"ejs": "^5.0.1", "ejs": "^5.0.1",
"express": "^5.1.0" "express": "^5.1.0",
"mysql2": "^3.22.1"
} }
} }

View file

@ -285,7 +285,7 @@ class Hazards extends WeatherDisplay {
id: hazard.id, id: hazard.id,
hazardType: hazard.properties?.event || 'Unknown', hazardType: hazard.properties?.event || 'Unknown',
severity: hazard.properties?.severity || '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 // Send to backend

View file

@ -1,141 +1,24 @@
/** import { getPool } from './mysql.mjs';
* Hazard History persistence module
* Tracks the last 7 hazard alerts encountered by this server instance
*/
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; const MAX_HISTORY_ENTRIES = 7;
/** const toIsoString = (value) => {
* Ensure the cache directory exists if (!value) return null;
*/ const date = value instanceof Date ? value : new Date(value);
const ensureCacheDir = async () => { return Number.isNaN(date.getTime()) ? null : date.toISOString();
const cacheDir = path.dirname(HISTORY_FILE);
try {
await mkdir(cacheDir, { recursive: true });
} catch (error) {
// Directory may already exist
}
}; };
/** const mapRowToHistoryEntry = (row) => ({
* Load hazard history from disk location: row.location_label,
* @returns {Array} Array of hazard history entries locationKey: row.location_key,
*/ hazardType: row.hazard_type,
const loadHistory = async () => { source: row.source,
try { severity: row.severity,
await ensureCacheDir(); latestHazardId: row.latest_hazard_id,
const data = await readFile(HISTORY_FILE, 'utf8'); encounteredAt: toIsoString(row.encountered_at),
const parsed = JSON.parse(data); lastSeenAt: toIsoString(row.last_seen_at),
return Array.isArray(parsed) ? parsed : []; ongoing: Boolean(row.ongoing),
} 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;
};
/** /**
* Format location label from weather parameters * Format location label from weather parameters
@ -147,120 +30,114 @@ const normalizeHistory = (history = []) => {
*/ */
const formatLocation = (city, state, country, countryCode) => { const formatLocation = (city, state, country, countryCode) => {
const cleanCity = city?.trim() || 'Unknown'; const cleanCity = city?.trim() || 'Unknown';
// US locations: "City, State"
if (countryCode === 'US' || countryCode === 'USA') { if (countryCode === 'US' || countryCode === 'USA') {
const cleanState = state?.trim(); const cleanState = state?.trim();
return cleanState ? `${cleanCity}, ${cleanState}` : cleanCity; return cleanState ? `${cleanCity}, ${cleanState}` : cleanCity;
} }
// Non-US locations: "City, Country"
const cleanCountry = country?.trim(); const cleanCountry = country?.trim();
return cleanCountry ? `${cleanCity}, ${cleanCountry}` : cleanCity; return cleanCountry ? `${cleanCity}, ${cleanCountry}` : cleanCity;
}; };
/** const getHistory = async () => {
* Update hazard history with current active hazards for a location const [rows] = await getPool().query(
* @param {Object} payload - Request payload `SELECT
* @param {string} payload.location - Formatted location label (for display) location_label,
* @param {string} payload.locationKey - Stable location key from lat/lon (for matching) location_key,
* @param {Array} payload.hazards - Array of active hazards hazard_type,
* @returns {Array} Updated history source,
*/ severity,
const updateHistory = async (payload) => { latest_hazard_id,
const { location, locationKey, hazards = [] } = payload; encountered_at,
last_seen_at,
// Load existing history ongoing
let history = normalizeHistory(await loadHistory()); FROM hazard_history
const now = new Date().toISOString(); ORDER BY last_seen_at DESC
LIMIT ?`,
// Use locationKey for matching if provided, fall back to location for backward compatibility [MAX_HISTORY_ENTRIES],
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 upgradedEntry = upgradeEntryLocationKey(entry, location, locationKey); return rows.map(mapRowToHistoryEntry);
// 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;
}; };
/** const updateHistory = async (payload) => {
* Get current hazard history const { location, locationKey, hazards = [] } = payload;
* @returns {Array} Current history entries const validHazards = hazards.filter((hazard) => hazard?.hazardType && hazard?.source);
*/ const pool = getPool();
const getHistory = async () => { const connection = await pool.getConnection();
const history = normalizeHistory(await loadHistory());
// Ensure sorted by lastSeenAt descending try {
return history.sort((a, b) => new Date(b.lastSeenAt) - new Date(a.lastSeenAt)); 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 { export {
loadHistory,
saveHistory,
updateHistory,
getHistory,
formatLocation, formatLocation,
generateKey, getHistory,
MAX_HISTORY_ENTRIES, MAX_HISTORY_ENTRIES,
updateHistory,
}; };

66
src/mysql.mjs Normal file
View file

@ -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,
};