Skip to content

docs(f4): an older backup is brought forward, not refused - #103

Merged
lessevv merged 2 commits into
mainfrom
spec/an-older-backup-brought-forward
Sep 12, 2026
Merged

lessevv merged 2 commits into
mainfrom
spec/an-older-backup-brought-forward

Conversation

@lessevv

@lessevv lessevv commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

An older backup is brought forward, not refused

Amends F4-R23/F4-R24 and adds F4-R26/F4-R27, from spec#96 (merged) and the flag raised on beatrax#715.

What changes, and why only half of it

F4-R23 refused a backup in either direction. Refusing the newer one is the only honest answer and that half stands: migrations do not roll back, so there is no path from that database to a shape this build reads, and a pending-migration check cannot even see the mismatch.

Refusing the older one was not honest. It is precisely the shape this build's own migrations were written to convert — and the desktop was already converting it, by accident, on the next request through EnsureDatabaseReady, a gate the phone does not have. Refusing it stranded most backups a reader holds more than a few weeks, and on mobile nearly all of them: the restore screen is reached from a fresh install, a fresh install is whatever version the store is serving, and the reader cannot choose an older one.

F4-R26 therefore brings it forward as part of the restore, on every shell rather than as a consequence of one shell's startup gate — and requires the reader to be told first, because a restore that changes the schema is a change in what the command promises.

F4-R27 is the load-bearing half, and it is measured

A migration that fails part way through is not recoverable on this store. SQLiteGrammar inherits $transactions = false, so Laravel does not wrap migrations in transactions on SQLite; the statements before the failure are applied and committed and the failing one is never recorded. The product repo already carries an incident of exactly this: a first-launch run died on migration eighteen of two hundred and nine and the app opened on thirteen tables of a hundred and two, with reinstall the only exit.

Measured directly against this build before writing the requirement, on a staged copy taken to migration 200 of 262 and then run forward with a failing step appended:

Observation Result
the forward run throws, so a caller can refuse yes
the failing migration's table is left behind yes
it is recorded as run no — so re-running meets its own half-finished work
the default connection is disturbed no

That is survivable on a copy and unsurvivable on the reader's ledger. So the run happens on the copy the restore has already staged, the swap reads that copy only once every migration has run, and a failure refuses with the live database never opened.

Also measured, because the requirement is worthless if the conversion does not converge: a database stopped at 200 migrations and then brought forward is byte-for-byte the same schema as a fresh install — 369 objects each, identical names, identical normalised SQL.

The ordering it settles

The forward run comes before the pre-restore snapshot. A refused restore still leaves nothing at all — no snapshot, no trace — which is the property F4-R25 and the existing refusal ordering already promise. A restore that migrates and then swaps is not a refusal, and takes its snapshot like any other. That was an open question in the previous PR and is now stated rather than inferred.

Ids

F4-R23 and F4-R24 are edited, not renumbered. F4-R26 and F4-R27 are new; F4-R25 is untouched. Nothing is renumbered and no vacated number is reused.

python3 scripts/integrity.py clean; markdownlint-cli2 clean.


F4-R28 added after review of the implementation. The page said the forward run happens on a copy, as a property of that run. It has to be a property of the restore: lifting a carried keyring out of a backup drops the table that carried it, and migrating it forward writes a schema into it — both consume the reader's file, and a refused restore has to leave them holding the file they started with. Stated unconditionally, because a guarantee that holds while a check agrees with the code it guards is not a guarantee. The implementation in beatrax#719 stages that copy on every restore rather than on the ones that need it.

F4-R23 refused a backup in either direction. Refusing the newer one is
the only honest answer — migrations do not roll back, so there is no
path from that database to a shape this build reads. Refusing the older
one was not: it is the shape this build's own migrations were written
to convert, and the desktop was already converting it, by accident, on
the next request through a gate the phone does not have.

The cost of refusing it was stranding most backups a reader holds more
than a few weeks, and on mobile nearly all of them — the restore screen
is reached from a fresh install of whatever the store is serving, and
the reader cannot choose an older version.

So F4-R26 brings it forward on every shell rather than as a consequence
of one shell's startup gate, and says so to the reader first: a restore
is now a schema-changing operation for that case, which is a change in
what it promises.

F4-R27 is where it runs. A migration that fails part way is not
recoverable on this store — the statements before it are applied and
committed, the failing one is not recorded, and re-running meets the
half of its own work that already landed. Measured: a forward run whose
last step throws leaves the table that step created and no row saying
it ran. So the run happens on the copy the restore already staged, the
swap reads that copy only once every migration has run, and a failure
refuses with the live database never opened.

That fixes the ordering too. The forward run comes before the
pre-restore snapshot, so a refused restore still leaves nothing at all,
and a restore that migrates and then swaps takes its snapshot like any
other.

F4-R24 is reworded: one direction is refused for being ahead, the other
only for failing to come forward, and those ask different things.

Signed-off-by: Wessel Verheij <info@nightworks.io>
…ites

Two things a restore does to a backup rewrite it. Lifting the carried
keyring out drops the table that carried it, leaving a backup that
restores the ledger once and the keys never again. Bringing an older
schema forward writes a whole schema into it, leaving one no earlier
build can read.

Both were already meant to happen on a copy, and the page said so about
the forward run only — as a property of that run rather than of a
restore. A reader whose restore was refused has to be able to hand the
same file to the next attempt, and it is often the only copy they have.

F4-R28 states it as the unconditional one it has to be: not "when the
backup carries a keyring", not "when something needs migrating".
A guarantee that holds while a check agrees with the code it guards is
not a guarantee.

Signed-off-by: Wessel Verheij <info@nightworks.io>
@sonarqubecloud

Copy link
Copy Markdown

@lessevv
lessevv merged commit 5868656 into main Sep 12, 2026
13 checks passed
@github-actions github-actions Bot added awaiting-maintainer Green PR with no review — a maintainer needs to act and removed awaiting-maintainer Green PR with no review — a maintainer needs to act labels Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-maintainer Green PR with no review — a maintainer needs to act documentation Docs only functional

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant