From 30aad51fac4173e8203f7d03ebcec1a3ce06d3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 02:12:49 +0200 Subject: [PATCH] Sweep the measurement caches after Google Health and Withings syncs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both hourly sync tails left the per-user analytics, correlations, targets, derived and achievements caches serving pre-sync bodies until their TTL lapsed — the correlations route documents the exact invariant these tails were breaking. Mirror the Fitbit tail: mark the cells stale whenever rows landed, on partial failures too. --- .../__tests__/sync-failsoft.test.ts | 34 +++++++++++++++++++ src/lib/google-health/sync.ts | 10 ++++++ .../__tests__/subscription-state.test.ts | 1 + .../__tests__/sync-measure-watermark.test.ts | 24 +++++++++++++ .../__tests__/sync-plausibility.test.ts | 1 + src/lib/withings/sync.ts | 10 +++++- 6 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/lib/google-health/__tests__/sync-failsoft.test.ts b/src/lib/google-health/__tests__/sync-failsoft.test.ts index 9783b7f93..1e7ea8d53 100644 --- a/src/lib/google-health/__tests__/sync-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-failsoft.test.ts @@ -18,6 +18,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { recordSyncFailureMock, recordSyncSuccessMock, + invalidateUserMeasurementsMock, prismaMock, getUserGoogleHealthCredentialsMock, refreshAccessTokenMock, @@ -25,10 +26,12 @@ const { } = vi.hoisted(() => ({ recordSyncFailureMock: vi.fn(async () => {}), recordSyncSuccessMock: vi.fn(async () => {}), + invalidateUserMeasurementsMock: vi.fn(), prismaMock: { googleHealthConnection: { findUnique: vi.fn(), update: vi.fn(async () => ({})), + updateMany: vi.fn(async () => ({ count: 1 })), }, }, getUserGoogleHealthCredentialsMock: vi.fn(async (): Promise => null), @@ -58,6 +61,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({ vi.mock("@/lib/insights/comprehensive-generate", () => ({ invalidateStatusInsightsForTypes: vi.fn(async () => {}), })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: invalidateUserMeasurementsMock, +})); vi.mock("../credentials", () => ({ getUserGoogleHealthCredentials: getUserGoogleHealthCredentialsMock, })); @@ -248,3 +254,31 @@ describe("getValidToken — dead-token cycle verdict", () => { } }); }); + +// Watched red: with the `invalidateUserMeasurements(userId)` call removed +// from the sync tail this fails — the pre-fix tail dropped only the +// per-type status insights, so correlations / analytics / targets served +// pre-sync bodies until TTL after every hourly sync. +describe("syncUserGoogleHealth — measurement-cache invalidation", () => { + it("marks the per-user measurement caches stale when rows landed", async () => { + invalidateUserMeasurementsMock.mockClear(); + prismaMock.googleHealthConnection.findUnique.mockResolvedValue({ + id: "conn-1", + userId: "user-1", + googleUserId: "g-user-1", + accessToken: "enc-access", + refreshToken: "enc-refresh", + // Comfortably outside the refresh buffer so getValidToken answers + // from the stored token without a refresh round-trip. + tokenExpiresAt: new Date(Date.now() + 60 * 60_000), + lastSyncedAt: new Date("2026-07-01T00:00:00.000Z"), + } as never); + const { decrypt } = await import("@/lib/crypto"); + vi.mocked(decrypt).mockReturnValue("tok" as never); + + const res = await syncUserGoogleHealth("user-1"); + + expect(res.imported).toBeGreaterThan(0); + expect(invalidateUserMeasurementsMock).toHaveBeenCalledWith("user-1"); + }); +}); diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index 90ef9fe7b..87cd3f9ba 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -7,6 +7,7 @@ import { recomputeUserRollups, } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { runWithGoogleHealthClientOutcome, type GoogleHealthClientOutcome, @@ -280,6 +281,15 @@ export async function syncUserGoogleHealth( await recordSyncSuccess(userId, GOOGLE_HEALTH_INTEGRATION_KEY); } + // Background-sync posture (mirrors the Fitbit tail): mark the per-user + // analytics / correlations / targets / achievements cells stale so the + // imported rows reach the cached readers before their TTL lapses — the + // correlations route documents exactly this invariant. Fires on a + // partial failure too: rows that DID land must not stay invisible. + if (total > 0) { + invalidateUserMeasurements(userId); + } + annotate({ action: { name: "googleHealth.sync", details: { imported: total, failed } }, }); diff --git a/src/lib/withings/__tests__/subscription-state.test.ts b/src/lib/withings/__tests__/subscription-state.test.ts index 4acac2b90..4267e6dfd 100644 --- a/src/lib/withings/__tests__/subscription-state.test.ts +++ b/src/lib/withings/__tests__/subscription-state.test.ts @@ -70,6 +70,7 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserDashboardSnapshot: vi.fn(), + invalidateUserMeasurements: vi.fn(), })); vi.mock("@/lib/logging/context", () => ({ diff --git a/src/lib/withings/__tests__/sync-measure-watermark.test.ts b/src/lib/withings/__tests__/sync-measure-watermark.test.ts index e8f37cd3e..e094d9af4 100644 --- a/src/lib/withings/__tests__/sync-measure-watermark.test.ts +++ b/src/lib/withings/__tests__/sync-measure-watermark.test.ts @@ -75,6 +75,7 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserDashboardSnapshot: vi.fn(), + invalidateUserMeasurements: vi.fn(), })); vi.mock("@/lib/logging/context", () => ({ @@ -225,4 +226,27 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", () }); expect(recordSyncFailure).not.toHaveBeenCalled(); }); + + // Watched red: with the `invalidateUserMeasurements(userId)` call removed + // from the sync tail this fails — the pre-fix tail evicted only the + // dashboard snapshot, so correlations / analytics / targets / derived + // served pre-sync bodies until their TTL lapsed after every hourly sync + // (the correlations route documents the invariant this restores). + it("marks the per-user measurement caches stale when rows landed", async () => { + vi.mocked(fetchMeasurements).mockResolvedValue([ + { + type: "WEIGHT", + value: 80, + measuredAt: new Date("2026-05-16T08:00:00Z"), + }, + ] as never); + + await syncUserMeasurements("user-1"); + + const { invalidateUserMeasurements } = + await import("@/lib/cache/invalidate"); + expect(vi.mocked(invalidateUserMeasurements)).toHaveBeenCalledWith( + "user-1", + ); + }); }); diff --git a/src/lib/withings/__tests__/sync-plausibility.test.ts b/src/lib/withings/__tests__/sync-plausibility.test.ts index 8aa7572c7..c980998a5 100644 --- a/src/lib/withings/__tests__/sync-plausibility.test.ts +++ b/src/lib/withings/__tests__/sync-plausibility.test.ts @@ -76,6 +76,7 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserDashboardSnapshot: vi.fn(), + invalidateUserMeasurements: vi.fn(), })); import { prisma } from "@/lib/db"; diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index d500a030c..8ec95fdd4 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -32,7 +32,10 @@ import { recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; -import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { + invalidateUserDashboardSnapshot, + invalidateUserMeasurements, +} from "@/lib/cache/invalidate"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { acquireProviderTokenRefreshLock, @@ -380,6 +383,11 @@ export async function syncUserMeasurements( // pre-sync body until its ~180 s TTL lapsed. if (keys.length > 0) { invalidateUserDashboardSnapshot(userId); + // Background-sync posture (mirrors the Fitbit tail): mark the + // per-user analytics / correlations / targets / achievements cells + // stale so the imported rows reach the cached readers before their + // TTL lapses — the correlations route documents this invariant. + invalidateUserMeasurements(userId); } } catch (err) { getEvent()?.addWarning(