Carry the bests, the badges and the environment days through backup and restore - #820
Merged
Conversation
…estore Four models leave the coverage-pending register with both ends and a round-trip proof: PersonalRecord, UserAchievement, EnvironmentContext and EnvironmentTravelLocation. Two new section modules hold reader and restore beside each other, the arrangement the visits, vaccinations, reminders and Coach sections already use. The register understated two of them. The bests are "recomputable in principle", which is true and does not help: nothing recomputes them, and a recomputation over a restored history would find different rows, because sample series and GPS routes never travel, a portable file omits deleted readings, and a record set in a reading that has since been corrected cannot be found twice. The badge's unlock date is worse. The evaluator prefers a persisted date over a derived one, and several of the dates it can still derive come from counters no backup carries, so a dropped row does not relock the badge, it re-earns it today. PersonalRecord.sourceMeasurementId is the reference that needed care, and it is the dangerous kind. The schema declares no relation for it; migration 0054 created the column as a real foreign key against measurements. Writing it blind is not a dangling pointer, it is a constraint violation that rolls the whole restore back and hands the operator an empty account. Measured, with the resolution removed: "Foreign key constraint violated on the constraint: personal_records_source_measurement_id_fkey", HTTP 500, nothing restored. So the restore runs after the measurements, resolves the pointer against the rows it actually wrote, nulls what it cannot resolve and reports it under a new personalRecordReference skip kind. An unlocked badge names a definition in the code catalogue, which drifts between the release that writes a file and the one that reads it. The id is carried and written back verbatim rather than judged, the same answer the vaccination restore gives an antigen slug it cannot resolve. The readings and the location periods travel as a pair because carrying the readings alone is worse than losing them. The environment resolver reads the periods live, so with them gone the nightly seven-day lookback re-resolves every trip day to the home location and upserts over the row: an account would come back with a fortnight abroad and then have it quietly rewritten as a fortnight at home, days after a restore that reported success. Day keys stay strings end to end, and fetchedAt is restored verbatim so a two-year-old provisional reading does not claim it was confirmed today. The round trip seeds bests that share a metric type and an instant and differ only in the sport slot, two badges earned years apart (one of them under an id this build no longer defines), and a trip day beside a home day, then reads each back column by column. Counting alone passes every one of the failures worth fearing here: slots dropped so a 5 km best is presented as a 10 km best, unlock dates stamped at restore time, a trip day re-attributed to the home city, a location period whose bounds no longer cover the reading it explains. Each of those was applied on purpose and each failed on its own assertion. Two schema comments claimed no read path selects a column that this backup now selects; both say what reads it instead of staying wrong.
MBombeck
force-pushed
the
work/backup-awards
branch
from
August 22, 2026 09:14
853afcb to
f95fb95
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four models come off the coverage register in two pairs, and each pair had a reason to be a pair.
The undeclared foreign key
PersonalRecord.sourceMeasurementIdis a real foreign key in the database,ON DELETE SET NULL, created in migration 0054. The Prisma schema declares no@relationfor it, so nothing in the type system knows it exists and it reads like an ordinary string column.Writing it blind is not a dangling pointer. It is a constraint violation that rolls the entire restore back. Proven rather than reasoned: writing it unresolved gives
Foreign key constraint violated on the constraint: personal_records_source_measurement_id_fkeyandexpected 500 to be 200.The restore resolves it against the measurements it actually wrote, nulls what it cannot, and reports under a new
personalRecordReferenceskip. Awards therefore restore after measurements, and that ordering is a requirement rather than a preference.achievementIdis the opposite case: a bare string against a code catalogue with no table behind it, so nothing constrains it. It is carried verbatim and deliberately not judged against the catalogue this build happens to ship, the same decision the vaccination restore makes for a retired antigen slug.Why the environment pair is a pair
The readings and the travel periods owe atomicity to each other rather than to any other section, which is why one function writes both.
The environment resolver reads travel periods live. Readings restored without their periods get re-resolved to home by the nightly seven-day lookback and upserted over: a fortnight abroad becomes a fortnight at home, days after a restore that reported success. That is a delayed rewrite of the data that did survive, not a gap in context.
Neither pair takes a purpose parameter
No tombstone column, no ciphertext, so portable and disaster-recovery payloads are byte-identical here. A parameter that changes nothing would advertise a distinction the file does not have.
Proof
Six mutations, each reverted and re-run green. The two worth naming:
metricSlot: nullfails with the running best losing the slot that makes it its own record. NULLs are distinct in the unique index, so the database does not catch that one at all: the fixture seeds two bests sharing metric type and instant, differing only by slot, precisely because counting and the constraint both miss it.Shifting a travel period by one day fails on the period no longer explaining the reading it was exported beside, and shifting it by ten days flips the
covershalf of the same assertion, which proves that half is not inert.Register claims corrected
PersonalRecordwas filed as "recomputable in principle". Nothing recomputes them, and a recomputation over a restored history would produce different rows: sample series and GPS routes never travel, portable files omit deleted readings, and a record set in a since-corrected reading cannot be found twice.EnvironmentTravelLocationwas filed as lost context. It is a delayed rewrite, per above.@internalschema comments claimed nothing reads these columns. Both now say what does.Register: 5 models remaining. Refs #186.