fix(backup): carry the aggregation provenance through backup and restore

The disaster-recovery backup selected every Measurement scalar except the
aggregation fields, so a restore brought every `stats:` aggregate back with no
provenance and the Apple Health authority ladder came back flat: an export.xml
source-day estimate could then overwrite a native HealthKit statistic.

`aggregationProvenance` now rides the select, the disaster-recovery
serialization, the payload schema and the restore mapping, following
`externalSourceVersion` exactly — including staying out of the reduced
portable shape, which carries the human-readable subset only. The schema field
is optional, so every backup written before this parses unchanged.

Restoring one of those older backups no longer recreates the ambiguity
migration 0263 repaired: an Apple Health row under a `stats:` identity with no
provenance comes back as LEGACY_UNKNOWN, the sentinel the cumulative drain
already writes in that situation, so a native sync or archive reprocess can
repair it. The gate is scoped as 0263 scoped its backfill — other providers
mint `stats:` identities for their cumulative dailies and have never carried
provenance, and ordinary point rows keep NULL.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 15:20:47 +02:00
1 parent f8f460bb66
commit 2a396dd23e
6 files changed
+242 -3

No files matched your search

@@ -1130,4 +1130,142 @@ describe("canonical disaster-recovery backup round-trip", () => {
);
expect(invalidateUserData).not.toHaveBeenCalled();
});
/**
* `aggregationProvenance` is what keeps an export.xml source-day estimate
* from overwriting a native HealthKit statistic. A backup that drops it
* restores a flattened authority ladder, so it has to survive the round
* trip — and a backup written before it rode the payload has to land in the
* documented repairable state rather than reproduce the ambiguity migration
* 0263 already repaired once.
*
* Mutation check: drop `aggregationProvenance` from the backup select or
* from the restore mapping and the first case goes red; drop the
* Apple-Health `stats:` fallback and the second goes red.
*/
it("round-trips the aggregation provenance of a stats aggregate", async () => {
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: "provenance-native",
type: "ACTIVITY_STEPS",
value: 8_600,
unit: "steps",
measuredAt: "2026-07-20T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
aggregationProvenance: "HEALTHKIT_STATISTICS",
},
{
id: "provenance-xml",
type: "ACTIVITY_STEPS",
value: 8_526,
unit: "steps",
measuredAt: "2026-07-21T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-21",
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "PROVENANCE_ROUNDTRIP",
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 },
orderBy: { measuredAt: "asc" },
select: { id: true, aggregationProvenance: true },
}),
).toEqual([
{
id: "provenance-native",
aggregationProvenance: "HEALTHKIT_STATISTICS",
},
{ id: "provenance-xml", aggregationProvenance: "EXPORT_XML_SOURCE_MAX" },
]);
});
it("marks an unlabelled Apple Health stats aggregate as legacy-unknown on restore", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
// A backup taken before the provenance rode the payload: no field at all.
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-25T00:00:00.000Z",
userId: admin.id,
measurements: [
{
id: "legacy-apple-stats",
type: "ACTIVITY_STEPS",
value: 8_526,
unit: "steps",
measuredAt: "2026-07-20T10:00:00.000Z",
source: "APPLE_HEALTH",
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20",
},
{
// Another provider's cumulative daily. It mints a `stats:` id too but
// has never carried provenance, so inventing one would be a lie.
id: "legacy-fitbit-stats",
type: "ACTIVITY_STEPS",
value: 7_100,
unit: "steps",
measuredAt: "2026-07-21T10:00:00.000Z",
source: "FITBIT",
externalId: "stats:steps",
},
{
// An ordinary point reading never had provenance either.
id: "legacy-point-row",
type: "PULSE",
value: 61,
unit: "bpm",
measuredAt: "2026-07-22T10:00:00.000Z",
source: "MANUAL",
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "PROVENANCE_LEGACY",
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 },
orderBy: { measuredAt: "asc" },
select: { id: true, aggregationProvenance: true },
}),
).toEqual([
{ id: "legacy-apple-stats", aggregationProvenance: "LEGACY_UNKNOWN" },
{ id: "legacy-fitbit-stats", aggregationProvenance: null },
{ id: "legacy-point-row", aggregationProvenance: null },
]);
});
});