mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Sweep the measurement caches after Google Health and Withings syncs
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.
This commit is contained in:
1 parent
4e12aef5fd
commit
30aad51fac
6 files changed
+79
-1
No files matched your search
@@ -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<unknown> => 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");
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
});
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user