When config.json schema changes between versions, register a migration function in src/config/migrations.js. On startup, loadSiteConfig() detects the stored version, runs all migrations forward, and writes the result back. Users never see the migration — it runs silently and the rest of the app only ever sees the current schema. Includes: - v0 → v1: normalize dns from string to object - v1 → v2: add dns.provider field (default 'technitium') - Forward compat: configs from future versions left untouched - Idempotent: re-running on already-migrated config is a no-op - Safe: no user data is removed during migration 21 unit tests covering edge cases: null input, forward compat, corrupt JSON, missing parent dirs, idempotency, full migration chain.
143 lines
4.4 KiB
JavaScript
143 lines
4.4 KiB
JavaScript
/**
|
|
* Config migration system
|
|
*
|
|
* When config.json schema changes between versions, register a migration
|
|
* function here. On load, the loader detects the stored version, runs all
|
|
* migrations from that version forward, and writes the result back.
|
|
*
|
|
* Migration format:
|
|
* migrations[<toVersion>] = (rawConfig) => { ...mutations, _version: toVersion }
|
|
*
|
|
* Each migration is responsible for transforming the previous version's
|
|
* shape into the next version's shape. They run sequentially, so v1→v2→v3
|
|
* all execute in order.
|
|
*
|
|
* For first-time users with no config file, the loader creates a fresh
|
|
* config with CURRENT_VERSION, so they start at the latest schema.
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const platformPaths = require('../../platform-paths');
|
|
|
|
const CURRENT_VERSION = 2;
|
|
|
|
/**
|
|
* Migrations: keys are the version they PRODUCE.
|
|
* Each migration takes a raw config object and returns the next version.
|
|
*/
|
|
const migrations = {
|
|
// v0 (unversioned) → v1: add _version field, normalize dns structure
|
|
1: (raw) => {
|
|
const migrated = { ...raw };
|
|
if (!migrated._version) migrated._version = 1;
|
|
// Normalize: older configs may have dns as a string IP, convert to object
|
|
if (typeof migrated.dns === 'string') {
|
|
migrated.dns = { ip: migrated.dns, port: 5380 };
|
|
} else if (!migrated.dns) {
|
|
migrated.dns = { ip: '', port: 5380 };
|
|
}
|
|
return migrated;
|
|
},
|
|
|
|
// v1 → v2: add dns.provider field (default: 'technitium' for backwards compat)
|
|
2: (raw) => {
|
|
const migrated = { ...raw };
|
|
if (migrated.dns && !migrated.dns.provider) {
|
|
migrated.dns.provider = 'technitium';
|
|
}
|
|
migrated._version = 2;
|
|
return migrated;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Run all migrations from `fromVersion` (or detected) to CURRENT_VERSION.
|
|
* @param {object} raw - The raw config object (may or may not have _version)
|
|
* @returns {object} The migrated config
|
|
*/
|
|
function migrate(raw) {
|
|
if (!raw || typeof raw !== 'object') {
|
|
// First-time load: return minimal config at current version
|
|
return { _version: CURRENT_VERSION };
|
|
}
|
|
|
|
const fromVersion = raw._version || 0;
|
|
if (fromVersion > CURRENT_VERSION) {
|
|
// Config from a future version — bail out, don't corrupt it
|
|
// The validation step will catch any actual issues
|
|
return raw;
|
|
}
|
|
|
|
let current = { ...raw };
|
|
for (let v = fromVersion + 1; v <= CURRENT_VERSION; v++) {
|
|
if (migrations[v]) {
|
|
current = migrations[v](current);
|
|
} else {
|
|
// No migration defined for this version, just bump _version
|
|
current._version = v;
|
|
}
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Load config from disk, run migrations if needed, and write back the
|
|
* migrated version. Safe to call on every startup.
|
|
* @param {string} configFile - Absolute path to config.json
|
|
* @param {object} log - Logger instance
|
|
* @returns {object} The migrated config object
|
|
*/
|
|
function loadAndMigrate(configFile, log) {
|
|
let raw = null;
|
|
let fileExisted = false;
|
|
|
|
if (fs.existsSync(configFile)) {
|
|
fileExisted = true;
|
|
try {
|
|
raw = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
} catch (e) {
|
|
if (log && log.error) {
|
|
log.error('config-migration', 'Failed to parse config.json, using defaults', { error: e.message });
|
|
}
|
|
raw = null;
|
|
}
|
|
}
|
|
|
|
const fromVersion = raw && raw._version ? raw._version : 0;
|
|
const migrated = migrate(raw);
|
|
|
|
// Only write back to disk if:
|
|
// 1. The file already existed (we don't create configs on fresh installs —
|
|
// the loader's defaults handle that case), AND
|
|
// 2. The version actually changed (no point rewriting identical content)
|
|
if (fileExisted && fromVersion < CURRENT_VERSION) {
|
|
if (log && log.info) {
|
|
log.info('config-migration', `Migrated config v${fromVersion} → v${CURRENT_VERSION}`, {
|
|
from: fromVersion,
|
|
to: CURRENT_VERSION,
|
|
path: configFile
|
|
});
|
|
}
|
|
// Write back the migrated config
|
|
try {
|
|
// Ensure parent dir exists
|
|
const dir = path.dirname(configFile);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(configFile, JSON.stringify(migrated, null, 2));
|
|
} catch (e) {
|
|
if (log && log.warn) {
|
|
log.warn('config-migration', 'Failed to write migrated config back to disk', { error: e.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
return migrated;
|
|
}
|
|
|
|
module.exports = {
|
|
CURRENT_VERSION,
|
|
migrations,
|
|
migrate,
|
|
loadAndMigrate
|
|
};
|