lollipopkit / lollipopkit/flutter_server_box
feat: roll the store back to an older schema version, for steps added from now on
@lollipopkit is already working on this.
Since Sep 18, 2026.
- Dominant language
- Dart
- Stars
- 8.7k
- Forks
- 558
- Avg merge
- 4h 31m
- Merged PRs (30d)
- 129
Description
## Problem
When `store.db` was written by a newer build, `SchemaVersion.migrate` refuses it and the app shows `SchemaTooNewPage`, which offers a raw SQLite copy and a wipe. No build can import that copy, so a user who cannot go back to the newer build (an expired beta, a downgraded DMG or APK) cannot back up, wipe and restore.
A `BackupV2` JSON does not help either: its `version` follows `SchemaVersion.current`, and an older build refuses a newer file.
## Proposal
Let an older build roll the store back to its own schema version, after showing what will be lost.
**Scope: schema steps added from now on.** Existing steps (up to v25) stay irreversible. The first build able to roll back is the one this ships in; data written by a build with any irreversible step above the reader's version keeps today's behaviour.
## Design
- **The build that applies a step writes its reverse.** An older build cannot know a step it has never seen. `SchemaMigration` gains a `down`, and applying a step stores the down SQL in the same transaction, e.g. `schema_down(version INTEGER PRIMARY KEY, sql TEXT NOT NULL)`.
- **Rollback requires every row.** On `SchemaTooNewException`, the reader checks that `schema_down` has a row for every version from `current + 1` to `stored`. If any is missing, rollback is refused and the rescue page is shown as today.
- **A down is SQL.** A step that reshapes kv JSON or `PrefStore` in Dart needs an SQL equivalent for its down, or it declares itself irreversible (no row), which blocks rollback across it.
- **Dry run on a copy first.** `DbRescue.exportTo` already produces a full copy. Run the downs on it, compare row counts per table against the original, and show the difference before asking for confirmation. Some downs lose data by construction: a table the step added, or a looser constraint that rows now depend on.
- **Swap only after it opens.** The live store is replaced by the rolled-back copy only after the copy opens at the reader's version. The original file is kept until then, so a failed rollback leaves the data where it was.
## Security
Down SQL is read from the database and executed. That is acceptable only for the device's own `store.db`, encrypted under the keychain key. It must never run for a file from elsewhere (an imported or rescue `.db`): a crafted file could use `ATTACH` to create files anywhere the sandbox allows. Importing such a file needs a path that executes nothing it contains.
## Tests
- Every step with a down: up then down against the fixture of the previous release, comparing schema and rows. Fixtures are never regenerated to make a test pass, as with `test/fixtures/hive_v*`.
- A missing `schema_down` row refuses rollback.
- The dry run reports dropped rows, and cancelling leaves the live store byte-identical.
- Rollback never executes SQL read from a file other than the live store.
- The three edits a schema step already needs (`CLAUDE.md`) become four: a step without a down must say so explicitly, so forgetting one is not silent.
## Not covered
Two branches that assign different steps to the same version numbers. Stored downs lead back along the branch that wrote them, not to the other branch's step with the same number. A branch must renumber its migrations after the target branch's before merging, and devices that ran the old numbering need their data recreated.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.