Files
HealthLog/src/lib/export/restore-skips.ts
T
Marc-André Bombeck 9c4235b66d fix(backup): a retired catalogue key must not cost the whole file
Three of the restore's lookups resolve a key against a seeded catalogue:
cycle symptoms, illness symptoms, mood factors. Each threw on a key it
could not resolve, inside the one transaction that wipes and rebuilds the
account. So a single renamed symptom rolled everything back and answered
500. No measurements, no doses, no notes, nothing. A year-old backup read
by a newer instance is the ordinary case for a backup, and the catalogue
drifts between those two dates by design.

The unresolvable link is dropped and named now. What it costs is one
association between a row and a symptom; the day-log, the mood entry, the
flow, the temperature and the encrypted note all come back. The count and
the exact keys ride the response, the audit row and the wide event, and
the admin surface shows them in a panel that stays put instead of a toast
that leaves. A quiet drop would be the older failure of this same seam
wearing a success message.

A dangling `cycleId` or a dose recorded against a medication the file
never carried still throws. Those name rows the file was supposed to hold,
so a missing one means the file contradicts itself. A catalogue key names
a row the instance owns, which is why it gets the other answer.

`IllnessSymptom` carries no owner column at all, so a key that will not
resolve there can only ever be drift; the file was never able to supply it.

The restore does not mint the missing catalogue row back. That would
preserve the link at the price of reference data colliding with whatever
the next migration seeds under that key, and a label nothing can translate.
2026-08-02 09:04:36 +02:00

90 lines
3.6 KiB
TypeScript

/**
* What a restore could not put back, and how much of it there was.
*
* Three of the restore's lookups resolve a key against a SEEDED CATALOGUE —
* cycle symptoms, illness symptoms, mood factors. 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 seeded catalogue a key failed to resolve against. */
export type SkippedCatalogue = "cycleSymptom" | "illnessSymptom" | "moodFactor";
/** 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),
};
}