fix: add missing migrateConfig method, config versioning, and formatters module
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,52 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
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 {
|
class ConfigManager {
|
||||||
constructor(relPath = '.') {
|
constructor(relPath = '.') {
|
||||||
this.configDir = path.resolve(__dirname, 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)
|
* @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) {
|
getConfig(configName) {
|
||||||
try {
|
try {
|
||||||
const configPath = path.resolve(this.configDir, `${configName}.json`);
|
const configPath = path.resolve(this.configDir, `${configName}.json`);
|
||||||
const configData = fs.readFileSync(configPath, 'utf8');
|
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) {
|
} catch (error) {
|
||||||
|
if (error.message && error.message.startsWith('Failed to load config')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw new Error(`Failed to load config '${configName}': ${error.message}`);
|
throw new Error(`Failed to load config '${configName}': ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +132,27 @@ class ConfigManager {
|
|||||||
return config;
|
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).
|
* Get the base config schema (shared across all nodes).
|
||||||
* @returns {object} Base config schema
|
* @returns {object} Base config schema
|
||||||
@@ -112,22 +163,14 @@ class ConfigManager {
|
|||||||
|
|
||||||
createEndpoint(nodeName) {
|
createEndpoint(nodeName) {
|
||||||
try {
|
try {
|
||||||
// Load the config for this node
|
|
||||||
const config = this.getConfig(nodeName);
|
const config = this.getConfig(nodeName);
|
||||||
|
|
||||||
// Convert config to JSON
|
|
||||||
const configJSON = JSON.stringify(config, null, 2);
|
const configJSON = JSON.stringify(config, null, 2);
|
||||||
|
|
||||||
// Assemble the complete script
|
|
||||||
return `
|
return `
|
||||||
// Create the namespace structure
|
|
||||||
window.EVOLV = window.EVOLV || {};
|
window.EVOLV = window.EVOLV || {};
|
||||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||||
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
||||||
|
|
||||||
// Inject the pre-loaded config data directly into the namespace
|
|
||||||
window.EVOLV.nodes.${nodeName}.config = ${configJSON};
|
window.EVOLV.nodes.${nodeName}.config = ${configJSON};
|
||||||
|
|
||||||
console.log('${nodeName} config loaded and endpoint created');
|
console.log('${nodeName} config loaded and endpoint created');
|
||||||
`;
|
`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
44
src/helper/formatters/csvFormatter.js
Normal file
44
src/helper/formatters/csvFormatter.js
Normal file
@@ -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 };
|
||||||
58
src/helper/formatters/index.js
Normal file
58
src/helper/formatters/index.js
Normal file
@@ -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 };
|
||||||
22
src/helper/formatters/influxdbFormatter.js
Normal file
22
src/helper/formatters/influxdbFormatter.js
Normal file
@@ -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 };
|
||||||
22
src/helper/formatters/jsonFormatter.js
Normal file
22
src/helper/formatters/jsonFormatter.js
Normal file
@@ -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 };
|
||||||
Reference in New Issue
Block a user