fix(rollups): fold measurement tier on /api/import + admin backup restore

Both ingest paths wrote into `measurements` without firing the rollup
write hook, leaving the dashboard chart's `source=rollup` fast-path
blind to the imported days. The mood / medication-compliance rollup
tiers already had post-batch hooks on these routes; the measurement
tier had been the gap.

- `/api/import`: tracks every touched `(type, measuredAt)` written
  during the batch, collapses to distinct `(type, day)` via
  `collapseToTypeDayKeys`, and folds once at the end. A 10 000-row
  CSV restore now pays at most ~N (type, day) recomputes rather than
  10 000 per-row hooks.
- Admin backup restore: the transaction wipes the user's
  `measurement_rollups` partition alongside the existing
  `mood_entry_rollups` wipe, and the post-transaction step kicks a
  `recomputeUserRollups(ownerId)` so the restored dataset paints
  fresh tiles instead of carrying the previous owner's daily means
  forward.
- Best-effort posture preserved in both paths: a populator hiccup
  surfaces via `annotate` but never undoes the restore / import.

Test coverage extends the existing `/api/import` route suite with a
regression that pins the per-`(type, day)` collapse and a duplicate-
write case (`P2002`) that proves the hook is skipped when nothing
was actually written.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 09:01:46 +02:00
1 parent ad22fa2e9d
commit a2b0ab19e5
3 files changed
+193 -1

No files matched your search

@@ -48,6 +48,7 @@ import {
recomputeUserMedicationCompliance,
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
} from "@/lib/medications/compliance-rollups";
import { recomputeUserRollups } from "@/lib/measurements/rollups";
export const dynamic = "force-dynamic";
@@ -291,6 +292,14 @@ const handler = apiHandler(
// pre-restore daily means. The fold below mints fresh rows
// from the restored mood entries.
await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } });
// v1.4.39.1 — wipe the persisted measurement rollup partition
// for the same reason. Pre-fix the partition kept the previous
// owner's daily means even after the underlying measurements
// were replaced — the chart's `source=rollup` fast-path could
// surface a stale 30-day mean built from rows that no longer
// existed. The fold below mints fresh rows from the restored
// measurement set.
await tx.measurementRollup.deleteMany({ where: { userId: ownerId } });
const channels = await tx.notificationChannel.deleteMany({
where: { userId: ownerId },
});
@@ -420,6 +429,29 @@ const handler = apiHandler(
return apiError("Restore failed", 500);
}
// v1.4.39.1 — re-fold the persistent measurement rollup tier from
// the just-restored measurements. Pre-fix the restore left the
// rollup table empty for the owner, so the dashboard chart's
// `source=rollup` fast-path silently returned zero buckets until
// the next worker boot ran the backfill discovery — multi-hour
// window of empty charts for the operator-restored account. Runs
// outside the transaction (the 5-year fold would otherwise hold a
// long write lock) and is best-effort so a populator hiccup never
// undoes the restore. The boot-time backfill is the safety net.
if (payload.measurements.length > 0) {
try {
await recomputeUserRollups(ownerId);
} catch (err) {
annotate({
meta: {
measurement_rollup_restore_failed: true,
measurement_rollup_restore_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
// v1.4.39 W-MOOD — re-fold the mood rollup tier from the just-
// restored entries. Runs outside the transaction so the (5-year)
// fold can't hold a long write lock; best-effort so a populator