From 31928fd1248d41bb7e9385f807c914cf90fa2372 Mon Sep 17 00:00:00 2001 From: Rene De Ren Date: Thu, 12 Mar 2026 09:33:22 +0100 Subject: [PATCH] fix: add missing migrateConfig method, config versioning, and formatters module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigManager.migrateConfig() was called but never defined — would crash at runtime. Added config version checking, migration support, and fixed createEndpoint indentation. New formatters module (csv, influxdb, json) for pluggable output formatting. Co-Authored-By: Claude Opus 4.6 --- src/configs/index.js | 83 ++++++++++++++++------ src/helper/formatters/csvFormatter.js | 44 ++++++++++++ src/helper/formatters/index.js | 58 +++++++++++++++ src/helper/formatters/influxdbFormatter.js | 22 ++++++ src/helper/formatters/jsonFormatter.js | 22 ++++++ 5 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 src/helper/formatters/csvFormatter.js create mode 100644 src/helper/formatters/index.js create mode 100644 src/helper/formatters/influxdbFormatter.js create mode 100644 src/helper/formatters/jsonFormatter.js diff --git a/src/configs/index.js b/src/configs/index.js index 6c926d3..80b9ab3 100644 --- a/src/configs/index.js +++ b/src/configs/index.js @@ -1,22 +1,52 @@ const fs = require('fs'); const path = require('path'); +/** + * Current config version. All config JSONs should declare this version. + * Bump this when the config schema changes. + */ +const CURRENT_CONFIG_VERSION = '1.0.0'; + class ConfigManager { constructor(relPath = '.') { this.configDir = path.resolve(__dirname, relPath); + + /** + * Migration functions keyed by "fromVersion->toVersion". + * Each function receives a config object and returns the migrated config. + * + * Example: + * this.migrations['1.0.0->1.1.0'] = (config) => { + * config.newSection = { enabled: false }; + * return config; + * }; + */ + this.migrations = {}; } /** - * Load a configuration file by name + * Load a configuration file by name. + * Automatically checks the config version and migrates if needed. * @param {string} configName - Name of the config file (without .json extension) - * @returns {Object} Parsed configuration object + * @returns {Object} Parsed configuration object (migrated to current version if necessary) */ getConfig(configName) { try { const configPath = path.resolve(this.configDir, `${configName}.json`); const configData = fs.readFileSync(configPath, 'utf8'); - return JSON.parse(configData); + let config = JSON.parse(configData); + + // Auto-migrate if version is behind current + const configVersion = config.version || '0.0.0'; + if (configVersion !== CURRENT_CONFIG_VERSION) { + config = this.migrateConfig(config, configVersion, CURRENT_CONFIG_VERSION); + } + + return config; } catch (error) { + if (error.message && error.message.startsWith('Failed to load config')) { + throw error; + } throw new Error(`Failed to load config '${configName}': ${error.message}`); } } @@ -102,6 +132,27 @@ class ConfigManager { return config; } + /** + * Migrate a config object from one version to another by applying + * registered migration functions in sequence. + * @param {object} config - The config object to migrate + * @param {string} fromVersion - Current version of the config + * @param {string} toVersion - Target version + * @returns {object} Migrated config with updated version field + */ + migrateConfig(config, fromVersion, toVersion) { + const migrationKey = `${fromVersion}->${toVersion}`; + const migrationFn = this.migrations[migrationKey]; + + if (migrationFn) { + config = migrationFn(config); + } + + // Stamp the current version so it won't re-migrate + config.version = toVersion; + return config; + } + /** * Get the base config schema (shared across all nodes). * @returns {object} Base config schema @@ -110,30 +161,22 @@ class ConfigManager { return this.getConfig('baseConfig'); } - createEndpoint(nodeName) { - try { - // Load the config for this node - const config = this.getConfig(nodeName); - - // Convert config to JSON - const configJSON = JSON.stringify(config, null, 2); + createEndpoint(nodeName) { + try { + const config = this.getConfig(nodeName); + const configJSON = JSON.stringify(config, null, 2); - // Assemble the complete script - return ` - // Create the namespace structure + return ` window.EVOLV = window.EVOLV || {}; window.EVOLV.nodes = window.EVOLV.nodes || {}; window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {}; - - // Inject the pre-loaded config data directly into the namespace window.EVOLV.nodes.${nodeName}.config = ${configJSON}; - console.log('${nodeName} config loaded and endpoint created'); - `; - } catch (error) { - throw new Error(`Failed to create endpoint for '${nodeName}': ${error.message}`); - } + `; + } catch (error) { + throw new Error(`Failed to create endpoint for '${nodeName}': ${error.message}`); } + } } module.exports = ConfigManager; \ No newline at end of file diff --git a/src/helper/formatters/csvFormatter.js b/src/helper/formatters/csvFormatter.js new file mode 100644 index 0000000..e84848d --- /dev/null +++ b/src/helper/formatters/csvFormatter.js @@ -0,0 +1,44 @@ +/** + * CSV formatter + * Produces a single CSV line: timestamp,measurement,field1=val1,field2=val2,... + * + * Values are escaped if they contain commas or quotes. + * + * @param {string} measurement - The measurement name (e.g. node name) + * @param {object} metadata - { fields, tags } + * - fields: key/value pairs of changed data points + * - tags: flat key/value string pairs (included as columns) + * @returns {string} CSV-formatted line + */ +function format(measurement, metadata) { + const { fields, tags } = metadata; + const timestamp = new Date().toISOString(); + const parts = [escapeCSV(timestamp), escapeCSV(measurement)]; + + // Append tags first, then fields + if (tags) { + for (const key of Object.keys(tags).sort()) { + parts.push(escapeCSV(`${key}=${tags[key]}`)); + } + } + + for (const key of Object.keys(fields).sort()) { + parts.push(escapeCSV(`${key}=${fields[key]}`)); + } + + return parts.join(','); +} + +/** + * Escapes a value for safe inclusion in a CSV field. + * Wraps in double quotes if the value contains a comma, quote, or newline. + */ +function escapeCSV(value) { + const str = String(value); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return '"' + str.replace(/"/g, '""') + '"'; + } + return str; +} + +module.exports = { format }; diff --git a/src/helper/formatters/index.js b/src/helper/formatters/index.js new file mode 100644 index 0000000..6c90667 --- /dev/null +++ b/src/helper/formatters/index.js @@ -0,0 +1,58 @@ +/** + * Formatter Registry + * ------------------ + * Maps format names to formatter modules. + * Each formatter exports: format(measurement, metadata) => string|object + * + * Usage: + * const { getFormatter, registerFormatter } = require('./formatters'); + * const fmt = getFormatter('json'); + * const output = fmt.format('pump1', { fields: {...}, tags: {...} }); + */ + +const influxdbFormatter = require('./influxdbFormatter'); +const jsonFormatter = require('./jsonFormatter'); +const csvFormatter = require('./csvFormatter'); + +// Built-in registry +const registry = { + influxdb: influxdbFormatter, + json: jsonFormatter, + csv: csvFormatter, +}; + +/** + * Retrieve a formatter by name. + * @param {string} name - Format name (e.g. 'influxdb', 'json', 'csv') + * @returns {object} Formatter with a .format() method + * @throws {Error} If the format name is not registered + */ +function getFormatter(name) { + const formatter = registry[name]; + if (!formatter) { + throw new Error(`Unknown output format: "${name}". Registered formats: ${Object.keys(registry).join(', ')}`); + } + return formatter; +} + +/** + * Register a custom formatter at runtime. + * @param {string} name - Format name + * @param {object} formatter - Object with a .format(measurement, metadata) method + */ +function registerFormatter(name, formatter) { + if (typeof formatter.format !== 'function') { + throw new Error('Formatter must have a .format(measurement, metadata) method'); + } + registry[name] = formatter; +} + +/** + * List all registered format names. + * @returns {string[]} + */ +function getRegisteredFormats() { + return Object.keys(registry); +} + +module.exports = { getFormatter, registerFormatter, getRegisteredFormats }; diff --git a/src/helper/formatters/influxdbFormatter.js b/src/helper/formatters/influxdbFormatter.js new file mode 100644 index 0000000..515e5eb --- /dev/null +++ b/src/helper/formatters/influxdbFormatter.js @@ -0,0 +1,22 @@ +/** + * InfluxDB formatter + * Produces the structured object expected by Node-RED InfluxDB nodes: + * { measurement, fields, tags, timestamp } + * + * @param {string} measurement - The measurement name (e.g. node name) + * @param {object} metadata - { fields, tags } + * - fields: key/value pairs of changed data points + * - tags: flat key/value string pairs (InfluxDB tags) + * @returns {string|object} Formatted payload (object for InfluxDB) + */ +function format(measurement, metadata) { + const { fields, tags } = metadata; + return { + measurement: measurement, + fields: fields, + tags: tags || {}, + timestamp: new Date(), + }; +} + +module.exports = { format }; diff --git a/src/helper/formatters/jsonFormatter.js b/src/helper/formatters/jsonFormatter.js new file mode 100644 index 0000000..43c756d --- /dev/null +++ b/src/helper/formatters/jsonFormatter.js @@ -0,0 +1,22 @@ +/** + * JSON formatter + * Produces a JSON string suitable for MQTT, REST APIs, etc. + * + * @param {string} measurement - The measurement name (e.g. node name) + * @param {object} metadata - { fields, tags } + * - fields: key/value pairs of changed data points + * - tags: flat key/value string pairs + * @returns {string} JSON-encoded string + */ +function format(measurement, metadata) { + const { fields, tags } = metadata; + const payload = { + measurement: measurement, + fields: fields, + tags: tags || {}, + timestamp: new Date().toISOString(), + }; + return JSON.stringify(payload); +} + +module.exports = { format };