fix(backup): keep an empty provenance empty on restore

A current disaster-recovery backup writes aggregationProvenance for every
measurement, null included. The restore treated null like a missing field
and marked every Apple Health stats: row without a provenance as
LEGACY_UNKNOWN, which is meant only for backups too old to carry the field.
The nightly daily-mean fold writes exactly such rows, so on a large Apple
Health account a restore changed tens of thousands of readings it was
supposed to put back as they were (#1031).

Only an absent field now takes the legacy path.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 22:40:08 +02:00
1 parent af65f01fec
commit fabd691128
2 files changed
+58 -4

No files matched your search

+11 -4
View File
@@ -768,11 +768,18 @@ export async function restoreBackup(
// their cumulative dailies too (Fitbit, Google Health) and have
// never carried provenance — marking those would invent a state
// they were never in. Ordinary point rows keep NULL honestly.
aggregationProvenance: (measurement.aggregationProvenance ??
(measurement.source === "APPLE_HEALTH" &&
measurement.externalId?.startsWith("stats:")
// Only an ABSENT field is the old-backup case. A current backup
// writes the field for every row, and an explicit null is the row's
// real state: the nightly daily-mean fold writes Apple Health
// `stats:` rows with no provenance. Treating that null as absent
// turned every such row into LEGACY_UNKNOWN on restore (#1031).
aggregationProvenance: (measurement.aggregationProvenance !==
undefined
? measurement.aggregationProvenance
: measurement.source === "APPLE_HEALTH" &&
measurement.externalId?.startsWith("stats:")
? "LEGACY_UNKNOWN"
: null)) as never,
: null) as never,
glucoseContext: (measurement.glucoseContext ?? null) as never,
sleepStage: (measurement.sleepStage ?? null) as never,
rhythmClassification: (measurement.rhythmClassification ??
@@ -1324,4 +1324,51 @@ describe("canonical disaster-recovery backup round-trip", () => {
{ id: "legacy-point-row", aggregationProvenance: null },
]);
});
it("keeps a provenance the backup states as empty, rather than marking it legacy", async () => {
// The nightly daily-mean fold writes Apple Health `stats:` rows with no
// provenance (respiratory rate, walking speed, …). A current backup says
// so with an explicit null, which is not the same as a backup too old to
// carry the field. Restoring those rows as LEGACY_UNKNOWN changed 18 618
// rows of a 2.6 million-reading account on every restore (#1031).
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-25T00:00:00.000Z",
userId: admin.id,
measurements: [
{
id: "daily-mean-stats",
type: "RESPIRATORY_RATE",
value: 14.2,
unit: "breaths/min",
measuredAt: "2026-07-20T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId:
"stats:HKQuantityTypeIdentifierRespiratoryRate:2026-07-20",
aggregationProvenance: null,
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "PROVENANCE_EXPLICIT_NULL",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
expect(
await prisma.measurement.findMany({
where: { userId: admin.id },
select: { id: true, aggregationProvenance: true },
}),
).toEqual([{ id: "daily-mean-stats", aggregationProvenance: null }]);
});
});