diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts index d391105e5..260a9c5b3 100644 --- a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it, vi } from "vitest"; +// The SUM drain now recomputes the affected DAY rollup bucket after each +// per-day fold (so a drained day's stale double-counted sum self-heals). +// Mock it: these are pure-logic tests over a Prisma mock with no DB, and +// the real recompute issues raw SQL against the singleton client. +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +})); +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + import { bucketRowsByUserDay, canonicalDailyTimestamp, @@ -444,6 +453,26 @@ describe("drainPerSampleCumulative — late-sync fold into existing total", () = const createArg = create.mock.calls[0]?.[0] as { data: { value: number } }; expect(createArg.data.value).toBe(500); }); + + it("recomputes the DAY rollup bucket for the drained day so the stale sum self-heals", async () => { + vi.mocked(recomputeBucketsForMeasurement).mockClear(); + const { prisma } = buildFoldMock(null); + + await drainPerSampleCumulative(prisma, { log: () => {} }); + + // Without this the rollup tier keeps serving the pre-drain + // double-counted sumValue forever (it reads only deleted_at IS NULL). + expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(1); + const [userId, type, canonicalTs] = vi.mocked( + recomputeBucketsForMeasurement, + ).mock.calls[0]!; + expect(userId).toBe("user-1"); + expect(type).toBe("ACTIVITY_STEPS"); + // Canonical local-noon of the Berlin day 2026-05-16 (CEST, UTC+2). + expect((canonicalTs as Date).toISOString()).toBe( + "2026-05-16T10:00:00.000Z", + ); + }); }); // Root cause of the v1.18.10 bloat: a single day whose mint collided with the diff --git a/src/lib/measurements/consolidate-legacy-steps.ts b/src/lib/measurements/consolidate-legacy-steps.ts index 4c3f8dc81..5ddfe1519 100644 --- a/src/lib/measurements/consolidate-legacy-steps.ts +++ b/src/lib/measurements/consolidate-legacy-steps.ts @@ -39,6 +39,7 @@ */ import type { PrismaClient } from "@/generated/prisma/client"; import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { dailyStatsExternalId } from "./apple-health-mapping"; import { @@ -259,6 +260,16 @@ export async function consolidateLegacySteps( }); removed = del.count; }); + + // Re-aggregate the affected (user, type, day) rollup bucket after the + // soft-delete commits. The legacy granular rows that just dropped out + // of the live read were being summed alongside any existing daily-stats + // total, so the DAY rollup bucket carried a double-counted total. The + // rollup tier reads only `deleted_at IS NULL`; without this recompute + // the stale bucket persists forever. Mirrors the mean drain's + // post-write recompute exactly. + await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); + return { kind: "written", sourceRowsRemoved: removed }; } catch (err) { // A pre-existing row can collide on the second unique index diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index 9bd92ca3b..319b42933 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -34,6 +34,7 @@ import { Prisma } from "@/generated/prisma/client"; import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { CUMULATIVE_HK_TYPES, @@ -355,6 +356,15 @@ export async function drainPerSampleCumulative( if (!isUniqueConstraintViolation(err)) throw err; removed = await foldOnce(); } + + // Re-aggregate the affected (user, type, day) rollup bucket after the + // fold commits — the per-sample rows are now gone and the canonical + // daily-sum row is the only live reading. The rollup tier reads only + // `deleted_at IS NULL`, so without this the day's DAY bucket keeps the + // stale pre-drain (double-counted) sumValue forever and never + // self-heals. Mirrors the mean drain's post-write recompute exactly. + await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); + return { kind: "written", sourceRowsRemoved: removed }; }, recordBucket: ({