Version the settings file so it can be upgraded
The schema is the single source of truth for what a setting IS. It cannot express what a setting USED to be -- and renaming a key, changing its units, or splitting one setting into two all leave a stored value the new schema does not recognise. Unrecognised keys are deliberately carried through untouched so that rolling back to an older Panama does not discard a newer version's settings, which means the user's choice silently stops taking effect with nothing to explain it. settings.json now carries a schemaVersion, and load() runs every pending migration before anything reads a value. The list is empty: the point is that the first breaking schema change becomes a routine edit rather than an emergency, and Omarchy carries eighty of these. The behaviours that make it safe to run against a real user's file: A file with no schemaVersion predates this and is STAMPED, not migrated -- running the list against it would apply upgrades designed for schemas it never had. A file from a NEWER Panama is left completely alone. Downgrading keys is not something this can do correctly, and unknown keys already survive, so an older build simply ignores what it does not understand. A step that throws stops at the last good version. Skipping past it would lose that conversion forever; failing the whole load would cost the user every setting. The list being empty is exactly why this is tested now: the first time it runs for real will be against somebody's actual settings during an upgrade, which is a poor moment to find out how it behaves. The harness supplies fixture steps including one that throws, and the contract pins all four behaviours above plus the promise that unknown keys survive. One thing the contract earned its place on: stamping a pre-versioning file changes it without running any step, so writing only on "migrated" left the stamp in memory to be redone on every launch. It now writes whenever the version moves, and explicitly does not write a file from the future. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -98,7 +98,26 @@ Singleton {
|
|||||||
// back to shipped defaults and let the next write replace it.
|
// back to shipped defaults and let the next write replace it.
|
||||||
parsed = {};
|
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.revision++;
|
||||||
root.loaded = true;
|
root.loaded = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ singleton PreferenceSchema 1.0 PreferenceSchema.qml
|
|||||||
singleton HomePreferences 1.0 HomePreferences.qml
|
singleton HomePreferences 1.0 HomePreferences.qml
|
||||||
singleton Settings 1.0 Settings.qml
|
singleton Settings 1.0 Settings.qml
|
||||||
singleton Theme 1.0 Theme.qml
|
singleton Theme 1.0 Theme.qml
|
||||||
|
singleton Migrations 1.0 Migrations.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+72
@@ -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'
|
||||||
Reference in New Issue
Block a user