Export and import the whole configuration as one declarative file (#23) #122

Merged
claude-bot merged 1 commit from feat/23-config-bundle into main 2026-09-04 22:59:30 +00:00
Contributor

Closes #23.

GET /export/config produces a versioned YAML bundle of settings, segments, user events and curated schemes. POST /import/config applies one, taking a backup first and running in a single transaction, so a bad file cannot half-migrate an instance.

What's deliberately not in it

  • Builtin events — regenerated per year, so exporting them adds rows that differ between instances by nothing that matters, and conflict on import against events the target already generated for itself.
  • Webhooks and the calendar feed token — credentials. A file whose whole purpose is to be committed to a repository is the wrong home for those.
  • Logs — history, not configuration.

The feed token is excluded structurally rather than by a filter: settings serialise through RuntimeSettings, and the token isn't one of its fields, so there's no path by which it reaches the file even if someone later adds it to the settings table. There's a test asserting the token doesn't appear in the output.

Determinism is the point of the format

Collections sort by a stable key and YAML is emitted with sorted keys, so an unchanged instance re-exports byte-identically and a real change produces a small diff.

One non-obvious choice: segments sort by name, not sort_order. sort_order is itself editable, so sorting by it would reshuffle the whole file whenever someone reordered the UI list — a diff that says nothing.

On the round-trip test, which is weaker than it looks

The obvious test is export → wipe → import → export → compare. I wrote it, and then checked whether it actually catches a dropped field by deleting effect_speed from the exporter.

It stayed green. A field missing from the exporter is missing from both bundles, so they still compare equal. It proves stability — import understands everything export writes — not completeness.

I'd written "catches a dropped field that per-field assertions would miss" in the docstring. That was wrong, and it's now corrected to say what the test actually does.

Completeness is a separate TestExportCoverage that walks the SQLAlchemy columns and fails when a model gains a field the exporter doesn't carry, with an explicit allow-list of deliberate omissions (surrogate keys, timestamps, stale). With effect_speed deleted, that one fails as it should. It's the test that will catch the next person adding a column.

This is the third time this session a test has turned out to agree with the code rather than with reality — after the leds.segs fixture and the preset mock aliasing.

Other behaviour worth noting

  • Schemes export when approved OR source: user#99 promotes on edit, so an edited-but-unapproved scheme is real work and dropping it would lose exactly what the bundle exists to preserve.
  • Import merges, so restoring one host's bundle onto another doesn't delete the second host's events. Segments are the exception and are replaced wholesale: they describe one physical strip, and merging two wirings would be meaningless.
  • A scheme whose event isn't present is skipped and counted, not written as a dangling row nothing resolves to.
  • Settings route through set_all_settings, so an import gets the same validation and unknown-key rejection the API applies rather than a second, looser path.

Verification

  • 531 backend tests pass (26 new), ruff, mypy clean
  • alembic check reports no drift
  • uvx pre-commit run --all-files passes
  • Both mutation checks done by hand: dropped field stays green on the round trip, fails on the coverage test

🤖 Generated with Claude Code

Closes #23. `GET /export/config` produces a versioned YAML bundle of settings, segments, user events and curated schemes. `POST /import/config` applies one, taking a backup first and running in a **single transaction**, so a bad file cannot half-migrate an instance. ### What's deliberately not in it - **Builtin events** — regenerated per year, so exporting them adds rows that differ between instances by nothing that matters, and conflict on import against events the target already generated for itself. - **Webhooks and the calendar feed token** — credentials. A file whose whole purpose is to be committed to a repository is the wrong home for those. - **Logs** — history, not configuration. The feed token is excluded **structurally** rather than by a filter: settings serialise through `RuntimeSettings`, and the token isn't one of its fields, so there's no path by which it reaches the file even if someone later adds it to the settings table. There's a test asserting the token doesn't appear in the output. ### Determinism is the point of the format Collections sort by a stable key and YAML is emitted with sorted keys, so an unchanged instance re-exports byte-identically and a real change produces a small diff. One non-obvious choice: segments sort by **name, not `sort_order`**. `sort_order` is itself editable, so sorting by it would reshuffle the whole file whenever someone reordered the UI list — a diff that says nothing. ### On the round-trip test, which is weaker than it looks The obvious test is export → wipe → import → export → compare. I wrote it, and then checked whether it actually catches a dropped field by deleting `effect_speed` from the exporter. **It stayed green.** A field missing from the exporter is missing from *both* bundles, so they still compare equal. It proves *stability* — import understands everything export writes — not *completeness*. I'd written "catches a dropped field that per-field assertions would miss" in the docstring. That was wrong, and it's now corrected to say what the test actually does. Completeness is a separate `TestExportCoverage` that walks the SQLAlchemy columns and fails when a model gains a field the exporter doesn't carry, with an explicit allow-list of deliberate omissions (surrogate keys, timestamps, `stale`). With `effect_speed` deleted, that one fails as it should. It's the test that will catch the next person adding a column. This is the third time this session a test has turned out to agree with the code rather than with reality — after the `leds.segs` fixture and the preset mock aliasing. ### Other behaviour worth noting - Schemes export when **approved OR `source: user`** — #99 promotes on edit, so an edited-but-unapproved scheme is real work and dropping it would lose exactly what the bundle exists to preserve. - Import **merges**, so restoring one host's bundle onto another doesn't delete the second host's events. Segments are the exception and are replaced wholesale: they describe one physical strip, and merging two wirings would be meaningless. - A scheme whose event isn't present is **skipped and counted**, not written as a dangling row nothing resolves to. - Settings route through `set_all_settings`, so an import gets the same validation and unknown-key rejection the API applies rather than a second, looser path. ### Verification - 531 backend tests pass (26 new), `ruff`, `mypy` clean - `alembic check` reports no drift - `uvx pre-commit run --all-files` passes - Both mutation checks done by hand: dropped field stays green on the round trip, fails on the coverage test 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Export and import the whole configuration as one declarative file (#23)
All checks were successful
CI / Pre-commit hooks (pull_request) Successful in 59s
CI / Dockerfile lint (pull_request) Successful in 9s
CI / Alembic migration check (pull_request) Successful in 1m5s
CI / Python lint & type-check (pull_request) Successful in 1m57s
CI / Frontend lint, test & build (pull_request) Successful in 1m7s
CI / Python tests (pull_request) Successful in 5m26s
CI / Docker build, health smoke & E2E (pull_request) Successful in 4m48s
762e179207
GET /export/config produces a versioned YAML bundle of settings, segments,
user events and curated schemes; POST /import/config applies one, taking a
backup first and running in a single transaction so a bad file cannot
half-migrate an instance.

Excluded on purpose: builtin events, which regenerate per year and would
add rows differing between instances by nothing that matters while
conflicting on import; webhooks and the calendar feed token, because a
file whose whole purpose is to be committed to a repository is the wrong
home for credentials; and the logs, which are history rather than
configuration. The feed token is excluded structurally -- settings
serialise through RuntimeSettings and there is no such field -- rather
than by a filter someone could later forget to update.

Determinism is the point of the format. Collections sort by a stable key
and YAML is emitted with sorted keys, so an unchanged instance re-exports
byte-identically and a real change produces a small diff. Segments sort by
name rather than sort_order, because sort_order is itself editable and
sorting by it would reshuffle the file whenever someone reordered the UI
list -- a diff that says nothing.

Schemes are exported when approved OR promoted to source=user, since #99
promotes on edit and an edited-but-unapproved scheme is real work. Import
merges rather than replaces, so restoring one host's bundle onto another
does not delete the second host's events. Segments are the exception and
are replaced wholesale: they describe one physical strip, and a merge of
two different wirings would be meaningless.

On the tests: the round trip (export, wipe, import, export, compare) is
the obvious test and it is weaker than it looks. It proves stability, not
completeness -- drop a field from the exporter and both bundles lack it
equally, so the comparison still passes. I checked that rather than
assuming, by deleting a field and watching it stay green. Completeness is
a separate test that walks the SQLAlchemy columns and fails when a model
gains a field the exporter does not carry, with an explicit allow-list of
deliberate omissions. That is the one that will catch the next column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/23-config-bundle 2026-09-04 22:59:30 +00:00
Sign in to join this conversation.
No description provided.