diff --git a/prisma/migrations/0272_drop_write_only_aggregation_columns/migration.sql b/prisma/migrations/0272_drop_write_only_aggregation_columns/migration.sql new file mode 100644 index 000000000..2f81a3122 --- /dev/null +++ b/prisma/migrations/0272_drop_write_only_aggregation_columns/migration.sql @@ -0,0 +1,36 @@ +-- v1.32.35 — drop the two write-only aggregation columns on `measurements`. +-- +-- Migration 0263 added three columns alongside the Apple Health `stats:*` +-- authority ladder. Only one of them was ever read back: +-- +-- * `aggregation_provenance` is load-bearing and STAYS. The external +-- measurement reconciler selects it and refuses a lower-authority write +-- against a higher-authority canonical row, which is what keeps an +-- export.xml source-day estimate from clobbering a native HealthKit +-- statistic. The cumulative drain reads it too. +-- * `aggregation_contributor_count` and `aggregation_selected_source_hash` +-- are written and never read. Three call sites write them (the batch +-- ingest and the cumulative drain write explicit NULLs, the export.xml +-- import writes real values); nothing selects them, no raw SQL names +-- them, they reach no export, no backup, no API contract and no repair +-- path. Verified by a repo-wide search over every tracked file for both +-- the Prisma field names and the column names. +-- +-- The import-time selection algorithm keeps its deterministic tiebreak: the +-- source hash it compares is an in-memory map key computed per flush, not a +-- value read back from this table. Only the persisted copies go. +-- +-- DATA BOUNDARY — read before editing. +-- +-- Both columns are plain nullable scalars added by 0263 with no index, no +-- unique, no check and no foreign key, so `DROP COLUMN` rewrites the tuples of +-- `measurements` and stops there. `DROP COLUMN` never cascades into another +-- table on its own, and there is no CASCADE keyword here. No measured value, +-- timestamp, unit, note or identity field is touched, and the documented +-- LEGACY_UNKNOWN repair path operates on `aggregation_provenance`, which +-- survives. In Postgres this is a catalog-only operation, so it is cheap even +-- on the largest table in the schema. + +ALTER TABLE "measurements" + DROP COLUMN IF EXISTS "aggregation_contributor_count", + DROP COLUMN IF EXISTS "aggregation_selected_source_hash"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2a4920eca..e2d69c31f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1636,13 +1636,7 @@ model Measurement { // source-day estimate derived from export.xml; migrated ambiguous rows are // explicitly LEGACY_UNKNOWN until a native sync or archive reprocess repairs // them. - aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance") - // Distinct source identities contributing to an export.xml source-day - // estimate. Source labels themselves are never persisted. - aggregationContributorCount Int? @map("aggregation_contributor_count") - // SHA-256 of the deterministically selected source identity. Non-identifying - // provenance for repeatability without retaining device/source labels. - aggregationSelectedSourceHash String? @map("aggregation_selected_source_hash") + aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance") // Only set for BLOOD_GLUCOSE measurements. NULL for all others. glucoseContext GlucoseContext? @map("glucose_context") // v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 1fe4aee66..8aa5ec5db 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -544,8 +544,6 @@ async function postBatch(request: NextRequest): Promise { .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], deviceType: p.row.deviceType as string | null, aggregationProvenance: statsRow ? "HEALTHKIT_STATISTICS" : null, - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }, { exactExternalMatch: statsRow ? "update" : "duplicate" }, ), diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index 389ba7d41..030f94be0 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -652,11 +652,7 @@ describe("streamParseExportXml — cumulative source-day estimates", () => { expect(prisma._measurements[0]).toMatchObject({ value: 8_526, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, }); - expect(prisma._measurements[0]?.aggregationSelectedSourceHash).toMatch( - /^[a-f0-9]{64}$/, - ); expect(result.cumulativeEstimates).toEqual({ days: 1, rows: 1 }); }); @@ -675,7 +671,6 @@ describe("streamParseExportXml — cumulative source-day estimates", () => { type: mapping.measurementType, value: 30, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, }); }, ); @@ -694,7 +689,6 @@ describe("streamParseExportXml — cumulative source-day estimates", () => { expect(prisma._measurements[0]).toMatchObject({ type: mapping.measurementType, value: 50, - aggregationContributorCount: 1, }); }, ); @@ -712,7 +706,6 @@ describe("streamParseExportXml — cumulative source-day estimates", () => { expect(prisma._measurements).toHaveLength(1); expect(prisma._measurements[0]).toMatchObject({ value: 100, - aggregationContributorCount: 1, }); }); @@ -1171,7 +1164,6 @@ describe("streamParseExportXml — memory ceiling", () => { expect(prisma._measurements).toHaveLength(1); expect(prisma._measurements[0]).toMatchObject({ value: 10_000, - aggregationContributorCount: 1, }); // Heap delta should be modest — we tolerate a generous 100 MB so a diff --git a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts index 1a755dc72..5a2d012fd 100644 --- a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts +++ b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts @@ -40,8 +40,6 @@ function row(overrides: Record = {}): Record { externalId: "sleep:new", externalSourceVersion: null, aggregationProvenance: null, - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, glucoseContext: null, sleepStage: "DEEP", rhythmClassification: null, @@ -224,8 +222,6 @@ describe("reconcileExternalMeasurement", () => { sleepStage: null, externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }); const { tx, measurement } = transaction([native]); @@ -240,8 +236,6 @@ describe("reconcileExternalMeasurement", () => { sleepStage: null, externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, - aggregationSelectedSourceHash: "source-hash", }), { exactExternalMatch: "update" }, ); @@ -350,8 +344,6 @@ describe("reconcileExternalMeasurement", () => { sleepStage: null, externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-07-20", aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }), { exactExternalMatch: "update" }, ); @@ -366,8 +358,6 @@ describe("reconcileExternalMeasurement", () => { data: expect.objectContaining({ value: 8_600, aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, syncVersion: { increment: 1 }, }), }), diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index 9fb503f77..81973acc7 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -352,8 +352,6 @@ export async function drainPerSampleCumulative( externalId, deletedAt: null, aggregationProvenance: "LEGACY_UNKNOWN", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, syncVersion: { increment: 1 }, ...(slotIsFreeForTarget ? { measuredAt: canonicalTimestamp } diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 030cd5b10..320e9c46f 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -680,7 +680,10 @@ export async function streamParseExportXml( } } if (!selected) continue; - const [selectedSourceHash, selectedSubtotal] = selected; + // Only the subtotal is carried forward. The source hash stays an + // in-memory map key — it is what makes the max-subtotal selection + // above deterministic on a tie, and it is not persisted. + const [, selectedSubtotal] = selected; if (validateMeasurementRange(type, selectedSubtotal) !== null) { unknown[`${type}::aggregate_out_of_range`] = (unknown[`${type}::aggregate_out_of_range`] ?? 0) + 1; @@ -704,8 +707,6 @@ export async function streamParseExportXml( sleepStage: null, deviceType: null, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: bySource.size, - aggregationSelectedSourceHash: selectedSourceHash, }, { exactExternalMatch: "update" }, ), diff --git a/tests/integration/measurement-identity-reconcile.test.ts b/tests/integration/measurement-identity-reconcile.test.ts index 9ebfa33ba..21ebecc12 100644 --- a/tests/integration/measurement-identity-reconcile.test.ts +++ b/tests/integration/measurement-identity-reconcile.test.ts @@ -114,8 +114,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => { measuredAt: new Date("2026-07-20T09:00:00.000Z"), sleepStage: null, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, - aggregationSelectedSourceHash: "xml-source-hash", }, ), }, @@ -132,8 +130,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => { externalSourceVersion: "native-v1", deviceType: "Apple Watch", aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }), }, }); @@ -147,8 +143,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => { measuredAt: nativeMeasuredAt, sleepStage: null, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, - aggregationSelectedSourceHash: "xml-source-hash", }), { exactExternalMatch: "update" }, ); @@ -171,8 +165,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => { externalSourceVersion: "native-v1", deviceType: "Apple Watch", aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }); expect( await prisma.measurement.findUniqueOrThrow({ @@ -194,8 +186,6 @@ describe("external measurement identity reconciliation (real Postgres)", () => { externalSourceVersion: "native-v2", deviceType: "Apple Watch", aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, }), { exactExternalMatch: "update" }, ); diff --git a/tests/integration/measurements-batch.test.ts b/tests/integration/measurements-batch.test.ts index d0e4c9ee7..1617bbc5f 100644 --- a/tests/integration/measurements-batch.test.ts +++ b/tests/integration/measurements-batch.test.ts @@ -601,8 +601,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(stored[0]!.value).toBeCloseTo(5200, 5); expect(stored[0]).toMatchObject({ aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, syncVersion: 2, }); }); @@ -1217,7 +1215,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(estimated[0]).toMatchObject({ value: 8_526, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, syncVersion: 1, }); @@ -1235,8 +1232,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(canonical[0]).toMatchObject({ value: 8_600, aggregationProvenance: "HEALTHKIT_STATISTICS", - aggregationContributorCount: null, - aggregationSelectedSourceHash: null, syncVersion: 2, }); }); @@ -1297,7 +1292,6 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(rows[0]).toMatchObject({ value: 8_526, aggregationProvenance: "EXPORT_XML_SOURCE_MAX", - aggregationContributorCount: 2, syncVersion: 2, }); });