Config migrations
Schemas change. Config files on disk do not — they sit in /etc on
machines nobody wants to touch during an upgrade. The framework's answer
is to make the schema's history part of the program: declare how each
version becomes the next, and files written by any supported release keep
loading forever.
Declaring the history#
Every config struct carries a version (see Your config struct). When the shape changes, keep the old shape as a type and add a step that converts it:
// the shape v1 files were written against — kept as history type ConfigV1 struct { Version uint32 `json:"version"` Addr string `json:"addr"` } // the shape today type Config struct { Version uint32 `json:"version"` Listen string `json:"listen" conf:"listen,l" usage:"address to serve on"` } fw.NewRegistration(ID, func() *Serve { return &Serve{cfg: Config{Version: 2, Listen: ":8080"}} }, func(s *Serve) *Config { return &s.cfg }, ). Alias("serve"). Migrate(fw.Step(1, func(old ConfigV1) Config { return Config{Listen: old.Addr} // renamed field, carried across })). Register()
fw.Step(from, fn) says "a document at version from becomes the next
version by running this function". Steps are listed oldest first, and the
chain is checked at startup rather than trusted:
- the
fromvalues must be contiguous, - each step's input type must be the previous step's output type,
- the last step's output must be exactly the current config struct,
- and the factory's default
Versionmust be one past the last step.
A chain that skips a version, or ends on the wrong type, is a startup error naming the problem. You cannot ship a migration that doesn't actually reach the current schema.
What happens at load#
The version in the file decides:
| Version in the file | What the framework does |
|---|---|
| the current version | loads normally, key by key |
| an older version with a chain | parses as that version, walks the chain, loads the result |
| older than the oldest step | error: no longer supported, naming the oldest version that is |
| newer than the binary | error: written by a newer schema than this binary |
| absent | warns, and treats the section as the current dialect |
The newer-than-binary case is the one that saves an afternoon. A rolled back binary meeting a config file the newer release rewrote fails immediately and says so, instead of silently ignoring the keys it doesn't recognise.
Two details worth knowing. The old document is parsed strictly against its own version's type, so a key from v3 appearing in a document claiming v1 is an error rather than a value that quietly survives. And a migrated section is copied in whole, so a versioned document is treated as complete — its zero values shade your defaults rather than falling back to them. Migrated values are still only the file layer: environment and arguments override them as usual.
Upgrading the files themselves#
Migrations keep old files working, but you may want the file modernised
on disk. --upgrade-config rewrites it in place, as a pure transform:
$ mytool --upgrade-config --config /etc/mytool/config.json
Pure means what it says — the environment and other arguments do not
leak into the result, and sections belonging to other tools pass through
verbatim. It runs against the whole catalog rather than one applet's
closure, so a multi-applet binary upgrades every section it owns in one
pass. An explicit --config target is required: there is no guessing
which discovered file you meant to rewrite.
A section with no version key needs you to say what it was, since guessing would risk running the wrong conversion:
$ mytool --upgrade-config --config app.json --from-version mytool=1
Checking without running#
--validate-config runs every check the startup path would — schemas,
domains, migrations, unknown keys — reports what it finds, and exits
without running anything (0 clean, 2 violated). It belongs in a
deployment pipeline, next to the config file it guards.
--help deliberately does not participate: it is best-effort, so a
broken config file never takes the help output down with it. And for
catching the same class of mistake before the binary exists at all, the
sxcli.dev/vet analyser checks
migration chains at compile time.
Next: Composition & the Builder — how a binary decides what it is made of.