Adopt the shared post-mutation tail everywhere a Measurement is written

The six measurement routes carried eight copy-pasted rollup-recompute +
status-invalidate blocks — the documented two-ended failure class
waiting for the next surface to remember one leg and forget the other.
All of them now call afterMeasurementMutation (the helper the deletion
fix introduced), and the two write surfaces that bypassed the hook
entirely join it: the nightly Recovery/Stress/Strain score rows and the
PHQ-9/GAD-7 assessment projection now recompute their (type, day)
bucket on the tick that writes them.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 05:59:12 +02:00
1 parent 6ebd5e8711
commit 14e9edfa7a
15 files changed
+89 -220

No files matched your search

@@ -28,6 +28,9 @@ vi.mock("@/lib/cache/invalidate", () => ({
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
+14 -57
View File
@@ -18,8 +18,7 @@ import {
} from "@/lib/validations/measurement";
import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
import { z } from "zod";
@@ -235,41 +234,14 @@ export const PUT = apiHandler(
// edit — hard-evict so the SWR readers don't serve the pre-edit body.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the rollup row for the affected day. When the
// measuredAt moved across day boundaries (or the row was re-typed)
// both the old and the new bucket need a recompute. Best-effort
// — a populator hiccup never fails the user's edit.
try {
await recomputeBucketsForMeasurement(
user.id,
measurement.type,
measurement.measuredAt,
);
if (
existing.measuredAt.getTime() !== measurement.measuredAt.getTime() ||
existing.type !== measurement.type
) {
await recomputeBucketsForMeasurement(
user.id,
existing.type,
existing.measuredAt,
);
}
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this edit
// dirties so the next mount / nightly warm pass regenerates against
// the new value. An edit can re-type a row, so invalidate both the
// old and the new type's scopes. Fire-and-forget: never blocks the
// user's edit.
invalidateStatusInsightsForTypes(user.id, [
existing.type,
measurement.type,
]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
// v1.37.19 (C2-F1) — shared post-mutation tail. Both the OLD and the
// NEW identity ride the list: an edit can move the row across day
// boundaries or re-type it, and the helper collapses identical
// (type, day) pairs so the common in-place edit fires once.
await afterMeasurementMutation(user.id, [
{ type: measurement.type, measuredAt: measurement.measuredAt },
{ type: existing.type, measuredAt: existing.measuredAt },
]);
return apiSuccess(shapeMeasurementNotes(measurement));
},
@@ -328,26 +300,11 @@ export const DELETE = apiHandler(
// body.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the rollup row for the affected day (the
// recompute drops the row when the day's measurement count goes
// to zero). Best-effort — a populator hiccup never fails the
// user's delete.
try {
await recomputeBucketsForMeasurement(
user.id,
existing.type,
existing.measuredAt,
);
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this deletion
// dirties so the next mount / nightly warm pass regenerates against
// the reduced history. Fire-and-forget: never blocks the user's delete.
invalidateStatusInsightsForTypes(user.id, [existing.type]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
// v1.37.19 (C2-F1) — shared post-mutation tail (the recompute drops
// the rollup row when the day's measurement count goes to zero).
await afterMeasurementMutation(user.id, [
{ type: existing.type, measuredAt: existing.measuredAt },
]);
return apiSuccess({ deleted: true });
},
@@ -117,6 +117,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
@@ -124,6 +124,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
@@ -97,6 +97,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
}));
@@ -124,6 +124,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
@@ -76,6 +76,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
@@ -74,6 +74,9 @@ vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
+5 -29
View File
@@ -77,11 +77,7 @@ import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { groupRowsByArrivalKind } from "@/lib/arrivals/measurement-kind";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/rollups/measurement-rollups";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
// v1.4.25 W16c — historical-backfill threshold for PR push
@@ -894,30 +890,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
// measurement routes already pass `{ evict: true }` for the same reason.
invalidateUserMeasurements(user.id, { evict: true });
// v1.5.0 — refresh the persistent rollup table for every distinct
// (type, day) the batch touched. Updates must participate as well as
// inserts, so this deliberately retains the prepared-row scope. Collapsed
// by day so a 500-row Apple Health batch fires only one recompute per
// type/day pair. Best-effort — a populator hiccup never fails ingest.
try {
const insertedKeys = collapseToTypeDayKeys(writtenIdentities);
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);
}
// v1.37.19 (C2-F1) — shared post-mutation tail for every distinct
// (type, day) the batch touched. Updates participate as well as
// inserts, so this deliberately retains the prepared-row scope.
await afterMeasurementMutation(user.id, writtenIdentities);
// S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION
// row for last night just landed, kick the debounced morning refresh so the
+6 -30
View File
@@ -30,11 +30,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/rollups/measurement-rollups";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import type { MeasurementType } from "@/generated/prisma/client";
const MAX_IDS_PER_BATCH = 200;
@@ -117,36 +113,16 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
// SWR readers don't serve the pre-delete body.
invalidateUserMeasurements(user.id, { evict: true });
// Collapse the deleted rows to the unique `(type, day)` set BEFORE
// recomputing so a 200-row delete spanning one day fires ~1 recompute
// per type, not 200 (mirrors the batch-insert path). Best-effort — a
// populator hiccup never fails the user's delete.
const keys = collapseToTypeDayKeys(
// v1.37.19 (C2-F1) — shared post-mutation tail (identities were
// collected BEFORE the destructive statement, while readable).
await afterMeasurementMutation(
user.id,
affected.map((row) => ({
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
"measurements bulk-delete",
);
try {
for (const k of keys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
} catch (err) {
console.warn("[measurements] bulk-delete rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows the deletion
// dirties so the next mount / nightly warm pass regenerates against
// the reduced history. Fire-and-forget: never blocks the delete.
const affectedTypes = Array.from(new Set(keys.map((k) => k.type)));
if (affectedTypes.length > 0) {
invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => {
console.warn(
"[measurements] bulk-delete status-insight invalidate failed",
err,
);
});
}
}
return apiSuccess({ deleted: count });
@@ -42,11 +42,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/rollups/measurement-rollups";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
const MAX_BATCH_ENTRIES = 500;
@@ -163,29 +159,9 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
if (result.count > 0) {
invalidateUserMeasurements(user.id);
// v1.5.0 — refresh the rollup row for every distinct (type, day)
// tuple the deletion touched. Collapsed by day so the same
// morning's deletes fold into one recompute per type. Best-
// effort — a populator hiccup never fails the user's reconciliation.
try {
const keys = collapseToTypeDayKeys(affectedRows);
for (const k of keys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows the deleted
// types dirty so the next mount / nightly warm pass regenerates
// against the reconciled history. Fire-and-forget: never blocks the
// iOS reconciliation.
invalidateStatusInsightsForTypes(
user.id,
affectedRows.map((row) => row.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
// v1.37.19 (C2-F1) — shared post-mutation tail (identities collected
// BEFORE the destructive statement, while readable).
await afterMeasurementMutation(user.id, affectedRows);
}
return apiSuccess({ deletedCount: result.count });
+6 -29
View File
@@ -32,11 +32,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/rollups/measurement-rollups";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import type { MeasurementType } from "@/generated/prisma/client";
const MAX_IDS_PER_BATCH = 200;
@@ -116,35 +112,16 @@ async function postRestore(request: NextRequest): Promise<Response> {
// hard-evict so the SWR readers don't serve the pre-restore body.
invalidateUserMeasurements(user.id, { evict: true });
// Collapse the restored rows to the unique `(type, day)` set BEFORE
// recomputing — mirrors the bulk-delete path. Best-effort: a
// populator hiccup never fails the user's restore.
const keys = collapseToTypeDayKeys(
// v1.37.19 (C2-F1) — shared post-mutation tail; mirrors the
// bulk-delete path.
await afterMeasurementMutation(
user.id,
affected.map((row) => ({
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
"measurements restore",
);
try {
for (const k of keys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
} catch (err) {
console.warn("[measurements] restore rollup recompute failed", err);
}
// Drop the cached per-metric assessment rows the restore dirties so
// the next mount / nightly warm pass regenerates against the
// restored history. Fire-and-forget: never blocks the restore.
const affectedTypes = Array.from(new Set(keys.map((k) => k.type)));
if (affectedTypes.length > 0) {
invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => {
console.warn(
"[measurements] restore status-insight invalidate failed",
err,
);
});
}
}
return apiSuccess({ restored: count });
+12 -47
View File
@@ -35,13 +35,9 @@ import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/rollups/measurement-rollups";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { readDailySeries } from "@/lib/measurements/daily-series-read";
import {
@@ -908,32 +904,14 @@ async function postMeasurement(request: NextRequest) {
{ action: "measurement.safety_floor.check" },
);
// v1.5.0 — refresh the persistent rollup table for every distinct
// (type, day) the batch touched so the next analytics / coach read
// hits the cache rather than falling through to live aggregation.
// Collapsed by day so a multi-entry batch on the same morning fires
// one DAY recompute per type instead of one per row. Best-effort
// — a populator hiccup never fails the user's write.
try {
const keys = collapseToTypeDayKeys(
results.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
);
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);
}
// v1.37.19 (C2-F1) — the shared post-mutation tail: rollup recompute
// (collapsed per (type, day)) + status-insight re-warm, both
// best-effort. One helper so a future write surface cannot remember
// one leg and forget the other.
await afterMeasurementMutation(
user.id,
results.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
);
return apiSuccess(shapedResults, 201);
}
@@ -1080,22 +1058,9 @@ async function postMeasurement(request: NextRequest) {
// pg-boss; WEEK / MONTH / YEAR recomputes are enqueued under the
// hood. Best-effort — a populator hiccup never fails the user's
// write.
try {
await recomputeBucketsForMeasurement(
user.id,
measurement.type,
measurement.measuredAt,
);
} catch (err) {
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);
});
await afterMeasurementMutation(user.id, [
{ type: measurement.type, measuredAt: measurement.measuredAt },
]);
return apiSuccess(shapeMeasurementNotes(measurement), 201);
}
@@ -27,6 +27,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
@@ -355,6 +356,17 @@ async function postAssessment(request: NextRequest): Promise<Response> {
});
invalidateUserHealthScore(user.id);
// v1.37.19 (A6-13) — the projection row is a Measurement like any other:
// route it through the shared post-mutation tail so the (type, day)
// rollup bucket and the cached status assessment converge without
// waiting for a nightly discovery pass.
await afterMeasurementMutation(user.id, [
{
type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType,
measuredAt: when,
},
]);
// v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on
// PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing
// a check-in resolves the reminder immediately (the ingest-route
+9
View File
@@ -23,6 +23,7 @@
*/
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -98,6 +99,14 @@ export async function upsertScoreRow(
measuredAt,
},
});
// v1.37.19 (A6-13) — a score row is a Measurement like any other: route
// it through the shared post-mutation tail so the (type, day) rollup
// bucket and the cached status assessment converge on the same tick that
// wrote the score, instead of waiting for a discovery pass.
await afterMeasurementMutation(args.userId, [
{ type: args.type, measuredAt },
]);
}
/** The tally every score pass returns. */