feat(backup): carry the health profile and custom metrics through export and restore

Both were classified as backed up when the backup plan was written and carried
by nothing. The custom metric is live data loss: a series the account defined
itself is the one kind no integration can re-sync, so a restore rebuilt the
account without the metric and every one of its readings, and reported success.

Builder and restore live in one file beside each other, the way the cycle pair
does — a grep of the restore route alone does not find either, because the route
delegates to both.

Readings nest inside the metric that owns them rather than referencing it by
name, so the restore has no parent to look up and no lookup to miss. A duplicate
metric name and an undecodable ciphertext field both throw and name the value.
Intake events whose medication did not restore now throw with the unresolved
reference instead of being filtered away into a dose history that comes back
short while the restore reports success.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:04:44 +02:00
1 parent ffe97d9ad9
commit c6593b292f
15 files changed
+826 -7

No files matched your search

@@ -38,6 +38,7 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import { restoreProfileData } from "@/lib/export/profile-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -64,6 +65,8 @@ interface RestoreResponse {
familyHistory: number;
workouts: number;
documents: number;
healthProfile: number;
customMetrics: number;
};
}
@@ -562,6 +565,32 @@ const handler = apiHandler(
}
if (payload.intakeEvents.length > 0) {
// An event whose medication did not come back used to be mapped to
// null and filtered away: the dose history came back short and the
// restore reported success. That is the same silent-drop shape the
// cycle symptom links had, and it gets the same answer — name the
// unresolved reference and stop, so the operator learns from the
// error rather than from a compliance rate that quietly moved.
const unresolvedMedications = [
...new Set(
payload.intakeEvents
.filter(
(e) =>
!(
e.medicationId &&
restoredMedicationIds.has(e.medicationId)
) && !medByName.has(e.medication),
)
.map((e) => e.medicationId ?? e.medication),
),
];
if (unresolvedMedications.length > 0) {
throw new Error(
`Unknown medication references in intake events: ${unresolvedMedications.join(
", ",
)}. The backup records doses against medications it does not carry.`,
);
}
const rows = payload.intakeEvents
.map((e) => {
const medId =
@@ -718,6 +747,12 @@ const handler = apiHandler(
// above. `notesEncrypted` is restored as ciphertext verbatim.
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
// Durable self-context + user-defined metrics. Both ends of this pair
// live in `src/lib/export/profile-backup.ts` beside its builder — a
// grep of THIS file alone will not find them, exactly as it does not
// find the cycle restore one line above.
const profileCleared = await restoreProfileData(tx, ownerId, payload);
const biomarkerByName = new Map<string, string>();
const restoredBiomarkerIds = new Set<string>();
for (const biomarker of payload.biomarkers) {
@@ -1073,6 +1108,8 @@ const handler = apiHandler(
familyHistory: familyHistory.count,
workouts: workouts.count,
documents: documents.count,
healthProfile: profileCleared.healthProfile,
customMetrics: profileCleared.customMetrics,
};
},
{
@@ -1197,6 +1234,9 @@ const handler = apiHandler(
familyHistory: summary.familyHistory,
workouts: summary.workouts,
documents: summary.documents,
healthProfile: summary.healthProfile,
customMetrics: summary.customMetrics,
customMetricEntries: summary.customMetricEntries,
},
},
});