feat(insights): drop the per-metric assessment cache on fresh measurements

Hang `invalidateStatusInsightsForTypes` off every measurement ingest
path so a new reading of a type drops the cached assessment rows that
reading dirties — the next mount or nightly warm pass then regenerates
them against the new data instead of serving the pre-measurement text
for the rest of the day.

Wired into both `/api/measurements` modes (single + multi-entry batch),
`/api/measurements/batch`, and the three Withings sync paths
(measures + activity + sleep), reusing the `collapseToTypeDayKeys`
result each already computes for its rollup recompute. The call is
fire-and-forget after the user's write commits: idempotent, never a
blocker on the request path, and a failure only warns.
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 22:05:31 +02:00
1 parent 177ce576d3
commit cd8849cd51
6 files changed
+153

No files matched your search

+12
View File
@@ -46,6 +46,7 @@ import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -484,6 +485,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
for (const k of insertedKeys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
// v1.8.0 — drop the cached per-metric assessment rows the ingested
// types dirty so the next mount / nightly warm pass regenerates
// them against the new data instead of serving stale text.
// Fire-and-forget: never a blocker on the user's ingest.
invalidateStatusInsightsForTypes(
user.id,
insertedKeys.map((k) => k.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
+18
View File
@@ -28,6 +28,7 @@ import {
} from "@/lib/measurements/drain-per-sample-cumulative";
import { withIdempotency } from "@/lib/idempotency";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -664,6 +665,16 @@ async function postMeasurement(request: NextRequest) {
for (const k of keys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
// v1.8.0 — drop the cached per-metric assessment rows the ingested
// types dirty so the next mount / nightly warm pass regenerates
// them against the new data. Fire-and-forget: never blocks ingest.
invalidateStatusInsightsForTypes(
user.id,
keys.map((k) => k.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
@@ -768,5 +779,12 @@ async function postMeasurement(request: NextRequest) {
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this type
// dirties so the next mount / nightly warm pass regenerates against
// the new row. Fire-and-forget: never blocks the user's write.
invalidateStatusInsightsForTypes(user.id, [measurement.type]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
return apiSuccess(measurement, 201);
}
@@ -0,0 +1,84 @@
/**
* v1.8.0 — measurement-driven per-metric assessment invalidation.
*
* A fresh measurement of a given type must drop the cached
* `insights.<scope>-status.<locale>` rows for the scopes that reading
* dirties, so the next mount / nightly warm pass regenerates them
* against the new data instead of serving the pre-measurement text for
* the rest of the day. These tests pin the type → scope mapping and the
* delete shape without a live DB.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const deleteMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { deleteMany: (...a: unknown[]) => deleteMany(...a) },
},
}));
import { invalidateStatusInsightsForTypes } from "../comprehensive-generate";
beforeEach(() => {
vi.clearAllMocks();
deleteMany.mockResolvedValue({ count: 0 });
});
function deletedScopes(): string[] {
const arg = deleteMany.mock.calls[0][0];
// Each OR clause is `{ action: { startsWith: "insights.<scope>-status." } }`.
return (arg.where.OR as Array<{ action: { startsWith: string } }>)
.map((c) => c.action.startsWith.replace(/^insights\.(.+)-status\.$/, "$1"))
.sort();
}
describe("invalidateStatusInsightsForTypes", () => {
it("maps WEIGHT to weight + bmi + general", async () => {
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(deleteMany).toHaveBeenCalledTimes(1);
expect(deletedScopes()).toEqual(["bmi", "general", "weight"]);
expect(deleteMany.mock.calls[0][0].where.userId).toBe("u1");
});
it("maps both blood-pressure components to blood-pressure + general", async () => {
await invalidateStatusInsightsForTypes("u1", [
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
]);
expect(deletedScopes()).toEqual(["blood-pressure", "general"]);
});
it("maps PULSE and RESTING_HEART_RATE to pulse + general", async () => {
await invalidateStatusInsightsForTypes("u1", [
"PULSE",
"RESTING_HEART_RATE",
]);
expect(deletedScopes()).toEqual(["general", "pulse"]);
});
it("maps an unmapped metric to general only", async () => {
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
expect(deletedScopes()).toEqual(["general"]);
});
it("dedupes scopes across a mixed batch", async () => {
await invalidateStatusInsightsForTypes("u1", [
"WEIGHT",
"PULSE",
"BLOOD_GLUCOSE",
]);
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ general
expect(deletedScopes()).toEqual([
"bmi",
"general",
"pulse",
"weight",
]);
});
it("is a no-op for an empty type set (no DB call)", async () => {
await invalidateStatusInsightsForTypes("u1", []);
expect(deleteMany).not.toHaveBeenCalled();
});
});
+12
View File
@@ -43,6 +43,7 @@ import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { hasActivityScope } from "./client";
import {
@@ -371,6 +372,17 @@ export async function syncUserActivity(
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
// v1.8.0 — drop the per-metric assessment caches the synced types
// dirty (steps feed the general overview). Fire-and-forget.
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`withings activity: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`withings activity: rollup recompute failed for ${userId}: ${err}`,
+13
View File
@@ -41,6 +41,7 @@ import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { hasActivityScope } from "./client";
import {
@@ -314,6 +315,18 @@ export async function syncUserSleep(
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
// v1.8.0 — drop the per-metric assessment caches the synced types
// dirty (sleep + resting-HR feed the pulse / general cards).
// Fire-and-forget.
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`withings sleep: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`withings sleep: rollup recompute failed for ${userId}: ${err}`,
+14
View File
@@ -28,6 +28,7 @@ import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
/**
* Build the callback URL handed to Withings at `Notify.subscribe` time.
@@ -250,6 +251,19 @@ export async function syncUserMeasurements(
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
// v1.8.0 — a Withings sync that lands new weight / BP / pulse /
// body-comp rows dirties the matching per-metric assessment caches.
// Drop them so the next mount / nightly warm pass regenerates
// against the new data. Fire-and-forget: never fails the sync.
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`withings: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`withings: rollup recompute failed for ${userId}: ${err}`,