mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user