fix(insights): re-warm generic metric assessments on measurement ingest

The ingest invalidation path mapped a fresh measurement only to the seven
specialised assessment scopes, so a freshly-synced HealthKit metric's generic
assessment card lagged its real-time data until the nightly warm pass —
an asymmetry against the near-real-time specialised cards.

Add a reverse `MeasurementType → metric id` index to the registry and extend
`invalidateStatusInsightsForTypes` so a reading of a type that backs a
registered generic metric also evicts and re-enqueues the matching
`metric:<ID>` scope, using the same single-locale, singleton-debounced
enqueue as the specialised scopes. Unregistered types and the seven
specialised metrics resolve to no generic scope, so the constant sync cannot
fan out to unwanted cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9bc0f116b46950c8ffa79d127d5dcce32ea644a4)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 14:03:00 +02:00
1 parent bd8a4e7990
commit 746e002104
3 files changed
+110 -11

No files matched your search

@@ -69,17 +69,27 @@ describe("invalidateStatusInsightsForTypes", () => {
expect(deletedScopes()).toEqual(["blood-pressure", "general"]);
});
it("maps PULSE and RESTING_HEART_RATE to pulse + general", async () => {
it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => {
await invalidateStatusInsightsForTypes("u1", [
"PULSE",
"RESTING_HEART_RATE",
]);
expect(deletedScopes()).toEqual(["general", "pulse"]);
// PULSE feeds the specialised pulse + general scopes; RESTING_HEART_RATE
// feeds those too AND carries its own generic assessment card
// (`metric:RESTING_HEART_RATE`), so all three are dirtied.
expect(deletedScopes()).toEqual([
"general",
"metric:RESTING_HEART_RATE",
"pulse",
]);
});
it("maps an unmapped metric to general only", async () => {
it("maps a metric with no specialised scope to general + its generic scope", async () => {
// BLOOD_GLUCOSE has no specialised assessment, but v1.8.7.1 gives it a
// generic `metric:BLOOD_GLUCOSE` card, so a fresh reading dirties the
// general overview AND that generic scope.
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
expect(deletedScopes()).toEqual(["general"]);
expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
});
it("dedupes scopes across a mixed batch", async () => {
@@ -88,10 +98,12 @@ describe("invalidateStatusInsightsForTypes", () => {
"PULSE",
"BLOOD_GLUCOSE",
]);
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ general
// weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪
// general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE)
expect(deletedScopes()).toEqual([
"bmi",
"general",
"metric:BLOOD_GLUCOSE",
"pulse",
"weight",
]);
@@ -133,7 +145,35 @@ describe("invalidateStatusInsightsForTypes", () => {
// pulse / bp scopes — the constant Apple-Health sync must not thrash
// every card's cache.
await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]);
expect(deletedScopes()).toEqual(["general"]);
expect(enqueuedScopes()).toEqual(["general"]);
expect(deletedScopes()).toEqual(["general", "metric:STEPS"]);
expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]);
});
it("re-warms the matching generic metric scope for a registered type", async () => {
// v1.8.7.1 — a blood-glucose sample dirties the general overview AND
// its own generic assessment card (`metric:BLOOD_GLUCOSE`), so the
// freshly-synced metric's card refreshes in the background instead of
// lagging until the nightly warm pass.
await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]);
expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]);
});
it("maps a remapped MeasurementType to its registry metric id", async () => {
// ACTIVE_ENERGY_BURNED is stored under that DB type but the generic
// metric id (route param + cache scope) is ACTIVE_ENERGY.
await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"]);
expect(enqueuedScopes()).toEqual(["general", "metric:ACTIVE_ENERGY"]);
});
it("does not emit a generic scope for an unregistered type", async () => {
// A weight sample feeds weight + bmi + general; WEIGHT is one of the
// seven specialised metrics and has no generic registry entry, so no
// `metric:` scope is enqueued.
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
expect(
enqueuedScopes().some((s) => s.startsWith("metric:")),
).toBe(false);
});
});
+33 -4
View File
@@ -49,6 +49,10 @@ import {
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared";
import { normalizeLocale } from "@/lib/insights/status-shared";
import {
metricIdForMeasurementType,
metricStatusScope,
} from "@/lib/insights/metric-status-registry";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -218,19 +222,37 @@ export async function invalidateStatusInsightsForTypes(
types: Iterable<MeasurementType>,
): Promise<void> {
const scopes = new Set<PerStatusScope>();
// v1.8.7.1 — a fresh measurement of a type that backs a generic
// HealthKit assessment card (the ~30 registry metrics) also dirties that
// card's `metric:<ID>` scope. Collect those alongside the seven
// specialised scopes so the generic tier is invalidated + re-warmed on
// ingest with the same debounced enqueue, rather than lagging until the
// nightly cron. Only registered, data-bearing types map here; the seven
// specialised metrics and any unregistered type resolve to null and are
// skipped, so the constant sync cannot fan out to unwanted scopes.
const metricScopes = new Set<`metric:${string}`>();
for (const type of types) {
for (const scope of statusScopesForMeasurementType(type)) {
scopes.add(scope);
}
const metricId = metricIdForMeasurementType(type);
if (metricId) {
metricScopes.add(metricStatusScope(metricId));
}
}
if (scopes.size === 0) return;
if (scopes.size === 0 && metricScopes.size === 0) return;
await prisma.auditLog.deleteMany({
where: {
userId,
OR: Array.from(scopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
OR: [
...Array.from(scopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
...Array.from(metricScopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
],
},
});
@@ -258,6 +280,13 @@ export async function invalidateStatusInsightsForTypes(
for (const scope of scopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
// v1.8.7.1 — re-warm the matching generic metric scopes the same way, so
// a freshly-synced HealthKit metric's assessment card refreshes in the
// background instead of lagging until the nightly warm pass. Same
// single-locale, singleton-debounced enqueue as the seven scopes above.
for (const scope of metricScopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
}
interface GenerateOptions {
@@ -421,6 +421,36 @@ export function getMetricStatusMeta(
return isMetricStatusId(metric) ? REGISTRY[metric] : null;
}
/**
* Reverse index: the DB `MeasurementType` → the generic metric id it backs
* (built once at module load). A handful of types map under a different id
* than their literal name (`ACTIVITY_STEPS` → `STEPS`,
* `ACTIVE_ENERGY_BURNED` → `ACTIVE_ENERGY`); every other registered type
* is its own id. Types with no registry entry (the seven specialised
* metrics, WORKOUTS, …) are simply absent.
*/
const MEASUREMENT_TYPE_TO_METRIC_ID = new Map<
MeasurementType,
MetricStatusMetricId
>(
(Object.values(REGISTRY) as MetricStatusMeta[]).map((meta) => [
meta.measurementType,
meta.id,
]),
);
/**
* Resolve the generic metric id a `MeasurementType` feeds, or null when
* the type carries no generic assessment card. Used by the ingest
* invalidation path to re-warm the matching `metric:<ID>` scope when a
* fresh measurement of a data-bearing type lands.
*/
export function metricIdForMeasurementType(
type: MeasurementType,
): MetricStatusMetricId | null {
return MEASUREMENT_TYPE_TO_METRIC_ID.get(type) ?? null;
}
/**
* The cache/scope id for a metric assessment. The `metric:` prefix keeps
* the generic scopes disjoint from the seven bare specialised scope