diff --git a/src/lib/export/awards-backup.ts b/src/lib/export/awards-backup.ts index ad8c665c7..2a37d3606 100644 --- a/src/lib/export/awards-backup.ts +++ b/src/lib/export/awards-backup.ts @@ -77,6 +77,7 @@ * sections. This builder therefore takes no `purpose`: a parameter that * changes nothing would advertise a distinction the file does not have. */ +import { isLegacyRmssdRow } from "@/lib/export/legacy-rmssd"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import type { MeasurementSource, @@ -292,9 +293,20 @@ export async function restoreAwardsData( ]); const danglingMeasurementRefs: string[] = []; - if (payload.personalRecords.length > 0) { + // A file written before migration 0420 can hold an SDNN best that a + // Fitbit or Google Health RMSSD reading set. The migration deleted those + // (that series keeps no records, and the detector re-derives the SDNN bests + // from what remains), so the restore leaves them out too. + const records = payload.personalRecords.filter( + (entry) => + !isLegacyRmssdRow({ + type: entry.metricType, + source: entry.source ?? null, + }), + ); + if (records.length > 0) { await tx.personalRecord.createMany({ - data: payload.personalRecords.map((entry) => { + data: records.map((entry) => { let sourceMeasurementId = entry.sourceMeasurementId ?? null; if (sourceMeasurementId && !measurementIds.has(sourceMeasurementId)) { danglingMeasurementRefs.push(sourceMeasurementId); diff --git a/src/lib/export/legacy-rmssd.ts b/src/lib/export/legacy-rmssd.ts new file mode 100644 index 000000000..50b3d6330 --- /dev/null +++ b/src/lib/export/legacy-rmssd.ts @@ -0,0 +1,20 @@ +/** The sources whose HRV migration 0420 refiled from SDNN to RMSSD. */ +export const LEGACY_RMSSD_SOURCES: ReadonlySet = new Set([ + "FITBIT", + "GOOGLE_HEALTH", +]); + +/** + * A Fitbit or Google Health HRV row a file written before migration 0420 + * still files as `HEART_RATE_VARIABILITY` (SDNN). It is RMSSD. + */ +export function isLegacyRmssdRow(row: { + type: string; + source?: string | null; +}): boolean { + return ( + row.type === "HEART_RATE_VARIABILITY" && + row.source != null && + LEGACY_RMSSD_SOURCES.has(row.source) + ); +} diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 5b6c12e94..7338d4ba5 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -74,6 +74,10 @@ import { encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; import { foldForMatch } from "@/lib/i18n/fold-for-match"; import type { BackupMeasurement } from "@/lib/validations/backup"; +import { + isLegacyRmssdRow, + LEGACY_RMSSD_SOURCES, +} from "@/lib/export/legacy-rmssd"; import { isCompatibleSchemaVersion, summarizeBackup, @@ -227,6 +231,15 @@ function readingsPhrase(count: number): string { return `${count} ${count === 1 ? "reading" : "readings"}`; } +/** Both unique identities of a row, as it would be filed under `HRV_RMSSD`. */ +function rmssdTwinKeysOf(row: BackupMeasurement): string[] { + const keys = [ + `at|${row.source}|${new Date(row.measuredAt).toISOString()}|${row.sleepStage ?? ""}`, + ]; + if (row.externalId) keys.push(`ext|${row.source}|${row.externalId}`); + return keys; +} + function decodeEncryptedBytes(encoded: string): Uint8Array { const decoded = Buffer.from(encoded, "base64"); const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); @@ -448,6 +461,29 @@ export async function restoreBackup( liveFoldStatsIds.add(foldStatsKey(row.source, row.type, row.externalId)); } }; + // Migration 0420 refiled Fitbit and Google Health HRV as `HRV_RMSSD`: both + // providers report a nightly RMSSD, which their mappers had written as the + // SDNN series. A file written before that release still carries those rows + // as `HEART_RATE_VARIABILITY`, and restoring it verbatim would undo the + // migration for the account. The restore applies the same move, and skips a + // legacy row whose RMSSD twin the file also holds (the migration leaves that + // row behind; a restore must not write it under either type, since writing + // it as RMSSD would collide with the twin on the unique key and writing it + // as SDNN would put RMSSD back into the SDNN series). The twins are gathered + // on the first read, under both unique identities of a measurement. + const rmssdTwinKeys = new Set(); + const noteRmssdTwin = (row: BackupMeasurement) => { + if ( + row.type === "HRV_RMSSD" && + row.source !== undefined && + LEGACY_RMSSD_SOURCES.has(row.source) + ) { + for (const key of rmssdTwinKeysOf(row)) rmssdTwinKeys.add(key); + } + }; + const isShadowedLegacyRmssd = (row: BackupMeasurement) => + isLegacyRmssdRow(row) && + rmssdTwinKeysOf(row).some((key) => rmssdTwinKeys.has(key)); // Set inside the transaction when the restore takes the account's fold // repair marker away; the repair is queued once the transaction commits. let foldRepairReset = false; @@ -460,6 +496,7 @@ export async function restoreBackup( progress.measurementsChecked = checked; report("validating"); noteLiveFoldRow(row); + noteRmssdTwin(row); }, }); progress.measurementsTotal = streamed.measurementCount; @@ -917,7 +954,9 @@ export async function restoreBackup( } const toRestoredMeasurementData = (measurement: BackupMeasurement) => ({ - type: measurement.type, + type: isLegacyRmssdRow(measurement) + ? ("HRV_RMSSD" as const) + : measurement.type, value: measurement.value, valueMin: measurement.valueMin ?? null, valueMax: measurement.valueMax ?? null, @@ -1094,10 +1133,13 @@ export async function restoreBackup( await streamed.forEachMeasurementBatch( measurementBatchSize, async (fileBatch) => { - const batch = fileBatch.filter( + const kept = fileBatch.filter( (measurement) => !isExpiredTombstone(measurement), ); - expiredTombstonesSkipped += fileBatch.length - batch.length; + expiredTombstonesSkipped += fileBatch.length - kept.length; + const batch = kept.filter( + (measurement) => !isShadowedLegacyRmssd(measurement), + ); const stableRows = batch.flatMap( (measurement): MeasurementInsertRow[] => measurement.id diff --git a/tests/integration/backup-legacy-rmssd-restore.test.ts b/tests/integration/backup-legacy-rmssd-restore.test.ts new file mode 100644 index 000000000..d65c888cb --- /dev/null +++ b/tests/integration/backup-legacy-rmssd-restore.test.ts @@ -0,0 +1,155 @@ +/** + * A backup written before migration 0420 restores Fitbit and Google Health + * HRV as RMSSD. + * + * Both providers report a nightly RMSSD, which their mappers used to file as + * `HEART_RATE_VARIABILITY` (the SDNN series). Migration 0420 moved those rows + * to `HRV_RMSSD`; a file written before it still carries them under the old + * type, and a verbatim restore would undo the migration for the account. The + * file here is built by the real writer from rows filed the old way, so it is + * the shape such a backup has. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST as RESTORE } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserData: vi.fn() }; +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("restoring a backup written before migration 0420", () => { + it("files Fitbit and Google Health HRV as RMSSD and keeps Apple SDNN", async () => { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "legacy-rmssd-owner", + email: "legacy-rmssd-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const row = ( + type: "HEART_RATE_VARIABILITY" | "HRV_RMSSD", + source: "FITBIT" | "GOOGLE_HEALTH" | "APPLE_HEALTH", + day: string, + value: number, + ) => ({ + userId: owner.id, + type, + source, + value, + unit: "ms", + measuredAt: new Date(`${day}T06:00:00.000Z`), + externalId: `${source.toLowerCase()}:${day}:hrv`, + }); + await prisma.measurement.createMany({ + data: [ + // Filed the old way. + row("HEART_RATE_VARIABILITY", "FITBIT", "2026-09-01", 41), + row("HEART_RATE_VARIABILITY", "GOOGLE_HEALTH", "2026-09-02", 38), + // A Fitbit night the file holds under both types: the RMSSD twin wins. + row("HEART_RATE_VARIABILITY", "FITBIT", "2026-09-03", 99), + row("HRV_RMSSD", "FITBIT", "2026-09-03", 44), + // A real SDNN reading stays where it is. + row("HEART_RATE_VARIABILITY", "APPLE_HEALTH", "2026-09-01", 52), + ], + }); + const legacyBest = await prisma.measurement.findFirstOrThrow({ + where: { userId: owner.id, source: "FITBIT", value: 99 }, + }); + await prisma.personalRecord.create({ + data: { + userId: owner.id, + metricType: "HEART_RATE_VARIABILITY", + direction: "MAX", + value: 99, + unit: "ms", + achievedAt: legacyBest.measuredAt, + sourceMeasurementId: legacyBest.id, + source: "FITBIT", + }, + }); + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await prisma.dataBackup.create({ + data: { + userId: owner.id, + type: "LEGACY_RMSSD_ROUND_TRIP", + data: encrypt(JSON.stringify(parsed)), + }, + }); + await prisma.personalRecord.deleteMany({ where: { userId: owner.id } }); + await prisma.measurement.deleteMany({ where: { userId: owner.id } }); + + const response = await RESTORE( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + const restored = await prisma.measurement.findMany({ + where: { userId: owner.id }, + select: { type: true, source: true, value: true }, + orderBy: [{ source: "asc" }, { measuredAt: "asc" }], + }); + expect(restored).toEqual([ + { type: "HEART_RATE_VARIABILITY", source: "APPLE_HEALTH", value: 52 }, + { type: "HRV_RMSSD", source: "FITBIT", value: 41 }, + { type: "HRV_RMSSD", source: "FITBIT", value: 44 }, + { type: "HRV_RMSSD", source: "GOOGLE_HEALTH", value: 38 }, + ]); + // The SDNN best an RMSSD reading set does not come back. + expect( + await prisma.personalRecord.count({ where: { userId: owner.id } }), + ).toBe(0); + }); +});