Skip to content

Carry the bests, the badges and the environment days through backup and restore - #820

Merged
MBombeck merged 1 commit into
mainfrom
work/backup-awards
Aug 22, 2026
Merged

Carry the bests, the badges and the environment days through backup and restore#820
MBombeck merged 1 commit into
mainfrom
work/backup-awards

Conversation

@MBombeck

Copy link
Copy Markdown
Owner

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.sourceMeasurementId is a real foreign key in the database, ON DELETE SET NULL, created in migration 0054. The Prisma schema declares no @relation for 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_fkey and expected 500 to be 200.

The restore resolves it against the measurements it actually wrote, nulls what it cannot, and reports under a new personalRecordReference skip. Awards therefore restore after measurements, and that ordering is a requirement rather than a preference.

achievementId is 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: null fails 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 covers half of the same assertion, which proves that half is not inert.

Register claims corrected

  • PersonalRecord was 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.
  • EnvironmentTravelLocation was filed as lost context. It is a delayed rewrite, per above.
  • Two @internal schema comments claimed nothing reads these columns. Both now say what does.

Register: 5 models remaining. Refs #186.

…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
MBombeck force-pushed the work/backup-awards branch from 853afcb to f95fb95 Compare August 22, 2026 09:14
@MBombeck
MBombeck merged commit a6c239d into main Aug 22, 2026
23 checks passed
@MBombeck
MBombeck deleted the work/backup-awards branch August 29, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant