Route measurement deletions into the rollup recompute path

A soft-deleted measurement never reached the rollup tier: the WHOOP
delete webhook tombstoned rows without recomputing the dirtied buckets,
and the freshness watermark probe filtered tombstones out, so the
deletion's updatedAt bump was invisible and the ghost aggregate read as
fresh forever.

Extract the post-write tail every measurement surface repeats into
afterMeasurementMutation (rollup recompute + status-insight
invalidation, both best-effort), call it from the WHOOP delete webhook
with the (type, measuredAt) pre-image collected before the updateMany,
and drop the deletedAt filter from the watermark probe so a tombstone
counts as staleness.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 01:22:41 +02:00
1 parent a3f0698525
commit 9fb5d7d7d0
6 files changed
+288 -3

No files matched your search

+27
View File
@@ -31,6 +31,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import type { MeasurementType } from "@/generated/prisma/client";
/**
* Maximum age of a webhook timestamp before it is treated as a replay. Five
@@ -163,6 +165,19 @@ export async function processWhoopNotification(
// match catches every measurement derived from the deleted resource.
if (resourceId) {
const now = new Date();
// Collect the affected `(type, measuredAt)` pairs BEFORE the
// soft-delete — the rollup tail below needs to know which
// `(type, day)` buckets lose rows, and after the updateMany the
// pre-image is gone.
const affected = await prisma.measurement.findMany({
where: {
userId: connection.userId,
source: "WHOOP",
externalId: { startsWith: `${resourceId}:` },
deletedAt: null,
},
select: { type: true, measuredAt: true },
});
await prisma.measurement.updateMany({
where: {
userId: connection.userId,
@@ -172,6 +187,18 @@ export async function processWhoopNotification(
},
data: { deletedAt: now, syncVersion: { increment: 1 } },
});
// Recompute the dirtied rollup buckets + drop the affected status
// insights — without this the aggregates keep counting the
// tombstoned rows forever (ghost-aggregate class). Best-effort
// inside the helper; never fails the 200 to WHOOP.
await afterMeasurementMutation(
connection.userId,
affected.map((row) => ({
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
"whoop.webhook",
);
if (resource === "workout") {
// The Workout model carries no soft-delete column, so a deleted
// WHOOP workout is removed outright (keyed by the canonical