mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +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
@@ -407,6 +407,25 @@ const handler = apiHandler(
|
||||
: decodeEncryptedBytes(measurement.notesEncrypted),
|
||||
externalId: measurement.externalId ?? null,
|
||||
externalSourceVersion: measurement.externalSourceVersion ?? null,
|
||||
// Authority of an Apple Health `stats:` aggregate. A backup
|
||||
// written before this field rode the payload carries nothing, and
|
||||
// restoring NULL would flatten the authority ladder: an export.xml
|
||||
// source-day estimate could then overwrite a native HealthKit
|
||||
// statistic, which is the ambiguity migration 0263 already
|
||||
// repaired once. Such a row is exactly the case LEGACY_UNKNOWN
|
||||
// exists for, so it comes back repairable instead of ambiguous.
|
||||
//
|
||||
// The gate is scoped exactly as 0263 scoped its backfill and as
|
||||
// the cumulative drain scopes its own LEGACY_UNKNOWN writes:
|
||||
// Apple Health rows only. Other providers mint `stats:` ids for
|
||||
// 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:")
|
||||
? "LEGACY_UNKNOWN"
|
||||
: null)) as never,
|
||||
glucoseContext: (measurement.glucoseContext ?? null) as never,
|
||||
sleepStage: (measurement.sleepStage ?? null) as never,
|
||||
rhythmClassification: (measurement.rhythmClassification ??
|
||||
|
||||
@@ -204,6 +204,7 @@ function installSectionMocks() {
|
||||
notesEncrypted: measurementNote,
|
||||
externalId: "oura-stage-deep",
|
||||
externalSourceVersion: "oura-v3",
|
||||
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
||||
glucoseContext: null,
|
||||
sleepStage: "DEEP",
|
||||
rhythmClassification: null,
|
||||
@@ -297,6 +298,7 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
|
||||
notesEncrypted: Buffer.from(measurementNote).toString("base64"),
|
||||
externalId: "oura-stage-deep",
|
||||
externalSourceVersion: "oura-v3",
|
||||
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
||||
glucoseContext: null,
|
||||
sleepStage: "DEEP",
|
||||
rhythmClassification: null,
|
||||
@@ -371,3 +373,72 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `aggregationProvenance` is what stops an export.xml source-day estimate from
|
||||
* overwriting a native HealthKit statistic. A disaster-recovery backup that
|
||||
* drops it restores a flattened authority ladder, so it rides the select and
|
||||
* the disaster-recovery serialization the same way `externalSourceVersion`
|
||||
* does — and stays out of the reduced portable shape, also like
|
||||
* `externalSourceVersion`.
|
||||
*
|
||||
* Mutation check: drop `aggregationProvenance: true` from the select, or drop
|
||||
* the field from the disaster-recovery arm, and the first two cases go red.
|
||||
*/
|
||||
describe("buildFullBackupPayload — aggregation provenance", () => {
|
||||
it("selects the provenance column", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
await buildFullBackupPayload(prisma as never, "user-1", {
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(mocks.iterateMeasurementPages).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ aggregationProvenance: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the provenance into the disaster-recovery payload", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
prisma as never,
|
||||
"user-1",
|
||||
{
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const rows = payload.measurements as Array<Record<string, unknown>>;
|
||||
expect(rows[0]).toHaveProperty(
|
||||
"aggregationProvenance",
|
||||
"HEALTHKIT_STATISTICS",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the provenance out of the reduced portable shape", async () => {
|
||||
installSectionMocks();
|
||||
const prisma = makePrisma();
|
||||
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
prisma as never,
|
||||
"user-1",
|
||||
{
|
||||
purpose: "portable-export",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const rows = payload.measurements as Array<Record<string, unknown>>;
|
||||
// The portable arm carries the human-readable subset only — no
|
||||
// externalSourceVersion, no glucoseContext, and no provenance either.
|
||||
expect(rows[0]).not.toHaveProperty("aggregationProvenance");
|
||||
expect(rows[0]).not.toHaveProperty("externalSourceVersion");
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,7 @@ export async function buildFullBackupPayload(
|
||||
notesEncrypted: true,
|
||||
externalId: true,
|
||||
externalSourceVersion: true,
|
||||
aggregationProvenance: true,
|
||||
glucoseContext: true,
|
||||
sleepStage: true,
|
||||
rhythmClassification: true,
|
||||
@@ -112,6 +113,7 @@ export async function buildFullBackupPayload(
|
||||
: null,
|
||||
externalId: measurement.externalId,
|
||||
externalSourceVersion: measurement.externalSourceVersion,
|
||||
aggregationProvenance: measurement.aggregationProvenance,
|
||||
glucoseContext: measurement.glucoseContext,
|
||||
sleepStage: measurement.sleepStage,
|
||||
rhythmClassification: measurement.rhythmClassification,
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
* personalization signal: which dashboard tiles get promoted to the
|
||||
* top and made visible. Goals are NOT a clinical target store — the
|
||||
* targets surface (`/api/insights/targets`) is fully derived from
|
||||
* height/age/gender + clinical references. Their only leverage is
|
||||
* (a) dashboard tile pinning (here) and (b) reminder suggestions
|
||||
* (read by the reminders panel from `User.onboardingGoals`).
|
||||
* height/age/gender + clinical references. Their only leverage is the
|
||||
* one-time dashboard tile pinning below, applied by
|
||||
* `/api/onboarding/step` while the layout is still unset. Nothing else
|
||||
* reads `User.onboardingGoals` — the column is collected, seeded from
|
||||
* once, and then inert.
|
||||
*
|
||||
* One home for the slug enum so the API validation
|
||||
* (`/api/onboarding/step`), the client picker, and the seeding all
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
InjectionSite,
|
||||
IntakeAttributionSource,
|
||||
IntakeSource,
|
||||
MeasurementAggregationProvenance,
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
MedicationCategory,
|
||||
@@ -86,6 +87,12 @@ const measurementSchema = z
|
||||
notesEncrypted: base64BytesSchema.nullable().optional(),
|
||||
externalId: z.string().nullable().optional(),
|
||||
externalSourceVersion: z.string().nullable().optional(),
|
||||
// Authority of a `stats:` aggregate. `.optional()` keeps every backup
|
||||
// written before this field rode the payload parseable.
|
||||
aggregationProvenance: z
|
||||
.enum(MeasurementAggregationProvenance)
|
||||
.nullable()
|
||||
.optional(),
|
||||
glucoseContext: z.enum(GlucoseContext).nullable().optional(),
|
||||
sleepStage: z.enum(SleepStage).nullable().optional(),
|
||||
rhythmClassification: z.enum(RhythmClassification).nullable().optional(),
|
||||
|
||||
@@ -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