pragma Singleton // Versioned upgrades for the settings file. // // The schema is the single source of truth for what a setting IS, but it cannot // describe what a setting USED to be. Renaming a key, changing its units, or // splitting one setting into two all leave a stored value that the new schema // does not recognise -- and an unrecognised key is silently carried through // untouched, so the user's choice simply stops taking effect with nothing to // say why. That is the failure this exists to prevent. // // HOW IT WORKS // // settings.json carries a schemaVersion. On load, every migration with a // version ABOVE the stored one runs in order, then the file is stamped with // `current`. A file with no schemaVersion at all is a file written before this // existed; it is stamped at `baseline` and NOT migrated, because those // migrations were never written for it. // // WRITING ONE // // { version: 2, describe: "rename dockDelay to dockHideDelayMs", // migrate: values => { ... return values; } } // // Rules that make this safe to run against a real user's file: // // * migrate() receives the whole values object and returns it. Mutating and // returning the same object is fine. // * NEVER delete a key you are not replacing. Unknown keys are deliberately // preserved so that rolling back to an older Panama does not discard a // newer version's settings, and a migration is the one place that promise // could quietly be broken. // * A migration must tolerate its input being absent or the wrong type. It // runs against files written by every previous version, including ones // that were hand-edited. // * Migrations never run twice: the stored version only moves forward. import QtQuick QtObject { id: root // What a file written today is stamped with. Bump this when adding a // migration, to the version of the migration you added. readonly property int current: 1 // Files predating versioning are stamped here without being migrated. readonly property int baseline: 1 readonly property string versionKey: "schemaVersion" // Ordered by version. Empty is the correct state until the first breaking // schema change -- this exists so that change is a routine edit rather than // an emergency. readonly property var steps: [] function storedVersion(values: var): int { const raw = values ? values[root.versionKey] : undefined; return (typeof raw === "number" && isFinite(raw)) ? Math.floor(raw) : 0; } // Returns { values, migrated, changed, from, to, applied }. // // `applied` names each step that ran, so the caller can log something // meaningful rather than "settings changed somehow". `changed` is the one // the caller should write on: stamping a pre-versioning file changes it // without running any step, and left unwritten the stamp would live only in // memory and be redone on every launch. function apply(values: var): var { return root.applyWith(values, root.steps, root.current, root.baseline); } // The same logic with the step list injected, so the machinery can be // tested against fixture migrations. The real list is empty until the first // breaking schema change, and a mechanism that has never run against a // failing step is not one to find out about during an upgrade. function applyWith(values: var, steps: var, current: int, baseline: int): var { const safe = (values && typeof values === "object") ? values : {}; const from = root.storedVersion(safe); // No version: written before versioning existed. Stamp it and stop. // Running the migration list against it would apply upgrades designed // for schemas this file never had. if (from === 0) { safe[root.versionKey] = baseline; return { values: safe, migrated: false, changed: true, from: 0, to: baseline, applied: [] }; } // A file from a NEWER Panama. Left completely alone: downgrading its // keys is not something this can do correctly, and unknown keys are // already preserved, so the older build simply ignores what it does not // understand. if (from > current) return { values: safe, migrated: false, changed: false, from: from, to: from, applied: [] }; const applied = []; let working = safe; for (const step of steps) { if (step.version <= from || step.version > current) continue; try { const result = step.migrate(working); if (result && typeof result === "object") working = result; applied.push(step.version + ": " + step.describe); } catch (error) { // One bad migration must not cost the user every setting. Stop // at the last good version so the next launch retries from // here rather than skipping the failed step forever. console.warn("Migrations: step", step.version, "failed:", error); working[root.versionKey] = step.version - 1; return { values: working, migrated: applied.length > 0, changed: applied.length > 0, from: from, to: step.version - 1, applied: applied }; } } working[root.versionKey] = current; return { values: working, migrated: applied.length > 0, changed: from !== current, from: from, to: current, applied: applied }; } }