diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 596d60ab9..25b353e4e 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -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 ?? diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 0ec4aaf03..44516b544 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -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[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 }]); + }); });