Files
HealthLog/src/lib/export/restore-skips.ts
T
Marc-André Bombeck f95fb95295 Carry the bests, the badges and the environmental history through a restore
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.
2026-08-22 11:14:15 +02:00

189 lines
9.3 KiB
TypeScript

/**
* What a restore could not put back, and how much of it there was.
*
* Four of the restore's lookups resolve a key against a SEEDED CATALOGUE —
* cycle symptoms, illness symptoms, rated mood factors and the mood tags a
* person ticks present or absent. The catalogue is reference
* data the instance owns, not data the file carries, so it legitimately drifts
* between the day a backup is written and the day it is read: a key gets
* renamed, a symptom is retired, the file is a year old, the instance is three
* releases newer. That is the ordinary case for a backup, not a corrupt file.
*
* Each of those lookups used to throw, which rolled the whole transaction back
* and answered 500 — one renamed symptom and the operator got none of the
* account back. Before that they filtered the unresolvable key away and
* reported success, which lost the same link with no trace at all. Neither is
* honest: the first destroys a recoverable restore, the second hides a real
* loss.
*
* So the unresolvable link is dropped and NAMED. This module is the naming.
* Every drop lands here, and the accumulated report rides the restore response,
* the audit row, and the wide event, so the count and the exact keys reach the
* person who ran the restore instead of a log line nobody reads.
*
* What is dropped is deliberately narrow: one (row, symptom) association. The
* day-log, the mood entry, the note, the flow, the temperature — everything the
* row itself holds — comes back. A link table is the only place this applies,
* because it is the only place where a value that will not resolve costs an
* edge rather than a record.
*/
/**
* Which lookup a key failed to resolve against.
*
* Four are seeded catalogues, as described above. The fifth, `visitReference`,
* is a different kind of unresolvable and is here rather than in its own
* mechanism because the cost and the honest response are identical: an edge is
* lost, the record it hangs off survives, and the operator is told which one.
* What it names is a row the file pointed at that the restore did not put
* back — a reminder the file does not carry (a portable export omits
* tombstoned ones since v1.37.20; before that no reminder travelled at all),
* or a document, lab result or condition episode whose id a portable export
* does not carry.
*
* The sixth, `vaccinationReference`, is the same kind as `visitReference` and
* is kept separate rather than folded into it so the report says which part of
* the record lost an edge. It names a row an immunization entry pointed at
* that the restore did not put back — a booster reminder the file does not
* carry, a practitioner or visit the file referenced without carrying, or a
* scanned page whose id a portable export does not include.
*
* The seventh, `reminderReference`, is the same kind as the two above, for
* the completion ledger (v1.37.20, #223 / iOS #68): a ledger row whose
* reminder the file does not carry. The builder filters the ledger to carried
* reminders at write time, so a file this release writes never trips it —
* it exists for the hand-edited or truncated file, where inventing the
* reminder and silently dropping the row are equally wrong.
*
* The eighth, `coachAttachment`, is the same kind again, for the join naming
* which vault documents a Coach conversation was grounded in. The document is
* restored before the conversations are, so a file this release writes always
* resolves: a disaster-recovery payload carries every document, and a portable
* payload never reaches a restore while the account has any, because the route
* refuses it ahead of the wipe. It exists for the hand-edited or truncated
* file, where inventing the document and silently dropping the provenance are
* equally wrong.
*
* The ninth, `coachReference`, names a conversation or a plan that a Coach
* fact, plan or reminder pointed at and the restore did not put back. It is
* unlike every kind above in one way worth stating: neither `sourceConversationId`
* nor `relatedPlanId` is a foreign key, so a dangling value costs no error and
* simply stops meaning anything. What is dropped here is therefore the POINTER
* and not the row — the fact, the plan and the reminder all restore, and the
* reminder still fires. A portable export omits tombstoned plans, which is the
* ordinary way a live reminder ends up naming one the file does not carry.
*
* The tenth and eleventh, `documentConditionLink` and `extractedFact`, name a
* vault row whose parent the restore did not put back — the page a filing was
* made against, the condition it was filed under, or the document a staged
* fact was read out of. All three are real foreign keys, so unlike every kind
* above the alternative to dropping the row is not a dangling pointer but an
* aborted transaction and no restore at all. The builder carries a filing or a
* fact only when both of its ends are carried, so a file this release writes
* never trips either — they exist for the hand-edited or truncated file.
*
* The twelfth, `factCommitment`, is the pointer kind rather than the row kind,
* exactly like `coachReference`: `ExtractedFact.committedRecordId` names the
* lab result, condition episode or medication an approved fact was committed
* to, and it is a bare id column with no relation, so a value pointing at
* nothing costs no error and simply stops meaning anything. The fact itself
* restores; what it loses is a pointer that was already going nowhere, and its
* `committedRecordType` goes with it so the row does not claim a commitment it
* cannot name.
*
* The thirteenth, `checkupClosure`, is not about a restore at all, and it borrows
* The tenth, `personalRecordReference`, names the measurement a personal best
* was found in. It reads like `coachReference` and is the opposite case in the
* one way that matters: `PersonalRecord.sourceMeasurementId` LOOKS like a bare
* id column in `prisma/schema.prisma`, which declares no relation for it, but
* migration 0054 created it as a real foreign key against `measurements`. So a
* dangling value here does not quietly stop meaning something. Postgres
* refuses the statement and the whole restore rolls back over one provenance
* pointer. What is dropped is therefore the POINTER and not the row: the best
* itself is the historical fact, which is why the column was declared
* `ON DELETE SET NULL` in the first place. The ordinary way it fails to
* resolve is a portable export whose source measurement was soft-deleted, and
* a legacy file whose measurements carry no ids at all.
*
* The eleventh, `checkupClosure`, is not about a restore at all, and it borrows
* this shape deliberately rather than growing a second reporting mechanism
* beside it. The situation is the same one: something a write was asked to do
* could not be done, the record itself survives, and the person is told which
* one rather than left to assume it worked. It is filed when a delegate files
* a visit against a preventive-care checkup their grant does not reach — the
* visit saves, the checkup stays due, and the response says so.
*/
export type SkippedCatalogue =
| "cycleSymptom"
| "illnessSymptom"
| "moodFactor"
| "moodTag"
| "visitReference"
| "vaccinationReference"
| "reminderReference"
| "coachAttachment"
| "coachReference"
| "documentConditionLink"
| "extractedFact"
| "factCommitment"
| "personalRecordReference"
| "checkupClosure";
/** One key this instance does not know, and the links it cost. */
export interface SkippedCatalogueKey {
catalogue: SkippedCatalogue;
/** The key exactly as the file wrote it. Reported verbatim so an operator
* can grep the file for it and see which days it was on. */
key: string;
/** How many links referenced it — twelve day-logs is twelve, not one. */
links: number;
}
/** Mutable accumulator threaded through one restore transaction. */
export type RestoreSkipLog = SkippedCatalogueKey[];
/** The report shape carried by the response, the audit row, and the UI. */
export interface RestoreSkipSummary {
/** Distinct unknown keys, ordered by catalogue then key. */
catalogueKeys: SkippedCatalogueKey[];
/** Total links dropped across every catalogue. Zero means nothing was lost. */
links: number;
}
/**
* Record the keys a catalogue lookup could not resolve.
*
* `referenced` is the FLAT list of keys the file used, one entry per link, not
* the deduplicated set the lookup queried with. A key that appears on twelve
* day-logs cost twelve links, and reporting it as one would understate the loss
* by a factor of twelve — which is the quiet-drop failure again, wearing a
* number.
*/
export function recordUnknownKeys(
log: RestoreSkipLog,
catalogue: SkippedCatalogue,
unresolved: readonly string[],
referenced: readonly string[],
): void {
for (const key of unresolved) {
log.push({
catalogue,
key,
links: referenced.filter((candidate) => candidate === key).length,
});
}
}
/** Fold the accumulator into the report the callers surface. */
export function summarizeRestoreSkips(log: RestoreSkipLog): RestoreSkipSummary {
const catalogueKeys = [...log].sort((a, b) =>
a.catalogue === b.catalogue
? a.key.localeCompare(b.key)
: a.catalogue.localeCompare(b.catalogue),
);
return {
catalogueKeys,
links: catalogueKeys.reduce((total, entry) => total + entry.links, 0),
};
}