mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
refactor(measurements): drop the write-only aggregation columns (migration 0272)
Migration 0263 added three columns beside the Apple Health `stats:*` authority ladder. Only `aggregation_provenance` is ever read back: the external-measurement reconciler selects it and refuses a lower-authority write against a higher-authority canonical row, and the cumulative drain reads it too. That column stays. `aggregation_contributor_count` and `aggregation_selected_source_hash` are written by three call sites and read by none. No select, no raw SQL, no export, no backup, no API contract, no repair path. The import-time selection keeps its deterministic tiebreak, which compares an in-memory map key computed per flush rather than a value read back from the table. Both are plain nullable scalars with no index, unique, check or foreign key, so the drop is a catalog-only operation that touches no measured value.
This commit is contained in:
1 parent
a3f1d780e7
commit
f8f460bb66
9 files changed
+41
-48
No files matched your search
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -544,8 +544,6 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
.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" },
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,8 +40,6 @@ function row(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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 },
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -352,8 +352,6 @@ export async function drainPerSampleCumulative(
|
||||
externalId,
|
||||
deletedAt: null,
|
||||
aggregationProvenance: "LEGACY_UNKNOWN",
|
||||
aggregationContributorCount: null,
|
||||
aggregationSelectedSourceHash: null,
|
||||
syncVersion: { increment: 1 },
|
||||
...(slotIsFreeForTarget
|
||||
? { measuredAt: canonicalTimestamp }
|
||||
|
||||
@@ -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" },
|
||||
),
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user