diff --git a/config/dot/quickshell/config/DesktopPreferences.qml b/config/dot/quickshell/config/DesktopPreferences.qml index 089c962..528a698 100644 --- a/config/dot/quickshell/config/DesktopPreferences.qml +++ b/config/dot/quickshell/config/DesktopPreferences.qml @@ -98,7 +98,26 @@ Singleton { // back to shipped defaults and let the next write replace it. parsed = {}; } - root.values = (parsed && typeof parsed === "object") ? parsed : {}; + const raw = (parsed && typeof parsed === "object") ? parsed : {}; + + // Upgrade before anything reads a value. A stored key the current + // schema no longer recognises is carried through untouched and silently + // stops taking effect, so the conversion has to happen here rather than + // being noticed later by whoever owns that setting. + const result = Migrations.apply(raw); + root.values = result.values; + + if (result.migrated) + console.info("Settings migrated from version", result.from, "to", result.to + ":", + result.applied.join("; ")); + + // Write whenever the version moved, which includes stamping a file + // written before versioning existed. Left unwritten, the stamp lives + // only in memory and is redone on every launch, and a migration that is + // not idempotent would compound. + if (result.changed) + persistTimer.restart(); + root.revision++; root.loaded = true; } diff --git a/config/dot/quickshell/config/Migrations.qml b/config/dot/quickshell/config/Migrations.qml new file mode 100644 index 0000000..05df23f --- /dev/null +++ b/config/dot/quickshell/config/Migrations.qml @@ -0,0 +1,121 @@ +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 }; + } +} diff --git a/config/dot/quickshell/config/qmldir b/config/dot/quickshell/config/qmldir index c792bf9..c138bb4 100644 --- a/config/dot/quickshell/config/qmldir +++ b/config/dot/quickshell/config/qmldir @@ -4,3 +4,4 @@ singleton PreferenceSchema 1.0 PreferenceSchema.qml singleton HomePreferences 1.0 HomePreferences.qml singleton Settings 1.0 Settings.qml singleton Theme 1.0 Theme.qml +singleton Migrations 1.0 Migrations.qml diff --git a/config/dot/quickshell/migrations-harness.qml b/config/dot/quickshell/migrations-harness.qml new file mode 100644 index 0000000..ae65a11 --- /dev/null +++ b/config/dot/quickshell/migrations-harness.qml @@ -0,0 +1,56 @@ +// Exercises Migrations.applyWith against fixture steps and prints a verdict per +// case. Run by tests/quickshell/migrations-contract.sh. +// +// Fixture steps rather than the real list: the real one is empty until the +// first breaking schema change, and a mechanism that has never been run against +// a failing step is not one to discover the behaviour of during an upgrade. + +import Quickshell +import QtQuick +import qs.config + +ShellRoot { + Component.onCompleted: { + const steps = [ + { version: 2, describe: "add b", migrate: v => { v.b = (v.a ?? 0) + 1; return v; } }, + { version: 3, describe: "add c", migrate: v => { v.c = "three"; return v; } }, + { version: 4, describe: "explode", migrate: v => { throw new Error("boom"); } } + ]; + const results = {}; + + // No version at all: a file written before versioning. Stamped, never + // migrated. + let r = Migrations.applyWith({ a: 1 }, steps, 3, 1); + results.unversioned = { v: r.values.schemaVersion, migrated: r.migrated, + changed: r.changed, untouched: r.values.b === undefined }; + + // Older file: every step above its version runs, in order. + r = Migrations.applyWith({ a: 1, schemaVersion: 1 }, steps, 3, 1); + results.upgrade = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c, + count: r.applied.length, migrated: r.migrated }; + + // Already current: nothing runs. + r = Migrations.applyWith({ schemaVersion: 3, keep: "me" }, steps, 3, 1); + results.current = { v: r.values.schemaVersion, migrated: r.migrated, + kept: r.values.keep === "me", b: r.values.b === undefined }; + + // From the future: left completely alone, including its unknown keys. + r = Migrations.applyWith({ schemaVersion: 9, futureKey: "x" }, steps, 3, 1); + results.future = { v: r.values.schemaVersion, migrated: r.migrated, + changed: r.changed, kept: r.values.futureKey === "x" }; + + // A failing step stops at the last good version rather than losing the + // file or skipping past the failure forever. + r = Migrations.applyWith({ schemaVersion: 1, a: 5 }, steps, 4, 1); + results.failure = { v: r.values.schemaVersion, b: r.values.b, c: r.values.c, + kept: r.values.a === 5, count: r.applied.length }; + + // Unknown keys survive a migration: rolling back to an older Panama + // must not discard a newer version's settings. + r = Migrations.applyWith({ schemaVersion: 1, unknownFromFuture: true }, steps, 3, 1); + results.preserved = { kept: r.values.unknownFromFuture === true }; + + console.info("PANAMA-MIGRATIONS " + JSON.stringify(results)); + Qt.callLater(() => Qt.quit()); + } +} diff --git a/tests/quickshell/migrations-contract.sh b/tests/quickshell/migrations-contract.sh new file mode 100755 index 0000000..6c663c7 --- /dev/null +++ b/tests/quickshell/migrations-contract.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +# Versioned upgrades for settings.json. +# +# The schema says what a setting IS; it cannot say what a setting USED to be. +# Rename a key, change its units, or split one setting into two, and the stored +# value stops being recognised -- and unrecognised keys are deliberately carried +# through untouched, so the user's choice silently stops taking effect with +# nothing to explain it. +# +# The list of migrations is empty today, which is exactly why this is tested +# now: the first time it runs for real will be against somebody's actual +# settings during an upgrade, and that is a poor moment to discover how it +# behaves. The harness supplies fixture steps, including one that throws. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/migrations-harness.qml" + +fail() { + printf 'migrations contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -r "$harness" ]] || fail "the harness is missing: $harness" + +out="$(timeout 60 qs -p "$harness" 2>&1 | grep -o 'PANAMA-MIGRATIONS .*' | sed 's/^PANAMA-MIGRATIONS //')" +[[ -n "$out" ]] || fail 'the harness produced no result' +jq -e . >/dev/null 2>&1 <<<"$out" || fail "the harness did not emit JSON: $out" + +check() { + jq -e "$1" >/dev/null <<<"$out" || fail "$2 -- got $(jq -c "$3" <<<"$out")" +} + +# A file written before versioning existed is stamped, NOT migrated. Running +# the list against it would apply upgrades designed for schemas it never had. +check '.unversioned.v == 1 and .unversioned.migrated == false and .unversioned.untouched == true' \ + 'a file with no schemaVersion must be stamped at the baseline without being migrated' '.unversioned' + +# The stamp has to reach disk. Reported as changed-but-not-migrated, it would +# otherwise live only in memory and be redone on every single launch. +check '.unversioned.changed == true' \ + 'stamping a pre-versioning file must be reported as a change so it gets written' '.unversioned' + +# A file from the future must not be rewritten at all. +check '.future.changed == false' \ + 'a file from a newer version must not be written back' '.future' + +# Every step above the stored version runs, in order. +check '.upgrade.v == 3 and .upgrade.b == 2 and .upgrade.c == "three" and .upgrade.count == 2' \ + 'an older file must run each pending step in order and end at the current version' '.upgrade' + +# Already current: nothing runs, nothing is touched. +check '.current.migrated == false and .current.kept == true and .current.b == true' \ + 'a file already at the current version must be left alone' '.current' + +# A file from a NEWER Panama is left completely alone. Downgrading keys is not +# something this can do correctly, and unknown keys are already preserved. +check '.future.v == 9 and .future.migrated == false and .future.kept == true' \ + 'a file from a newer version must not be modified or downgraded' '.future' + +# A failing step stops at the last good version. Skipping past it would lose +# the conversion forever; failing the whole load would cost every setting. +check '.failure.v == 3 and .failure.count == 2 and .failure.kept == true' \ + 'a failing step must stop at the last good version, keeping the steps that succeeded' '.failure' + +# The promise that makes rollback safe. +check '.preserved.kept == true' \ + 'a migration must not discard keys it does not recognise' '.preserved' + +printf 'migrations contract: PASS\n'