fix(backup): restore pre-0420 Fitbit and Google Health HRV as RMSSD

A backup written before migration 0420 files Fitbit and Google Health
HRV as HEART_RATE_VARIABILITY, and the restore wrote it back verbatim,
undoing the migration for the account (and able to duplicate an
HRV_RMSSD row of the same night, since type is part of the unique key).
The restore now applies the same move: those rows come back as
HRV_RMSSD, a legacy row whose RMSSD twin the file also holds is skipped,
and SDNN personal records set by them are left out as the migration
deleted them.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 16:25:25 +02:00
1 parent 65c296d430
commit 8e2e4f658b
4 files changed
+234 -5

No files matched your search

+14 -2
View File
@@ -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);
+20
View File
@@ -0,0 +1,20 @@
/** The sources whose HRV migration 0420 refiled from SDNN to RMSSD. */
export const LEGACY_RMSSD_SOURCES: ReadonlySet<string> = 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)
);
}
+45 -3
View File
@@ -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<ArrayBuffer> {
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<string>();
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
@@ -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<typeof import("@/lib/cache/invalidate")>();
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<typeof RESTORE>[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);
});
});