mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
v1.4.35 — persistent measurement rollups + partial read-swap
Foundation release for the persistent measurement_rollups cache tier. Adds the additive table + enum, populates it from every measurement write hook, lands the Apple-Health-import end-of-job backfill, and ships the parity-checked read-swap on two reader surfaces. The comprehensive insights aggregator and the slim analytics summaries slice now source count / min / max / mean per type from the DAY rollup buckets via aggregateBuckets, plus the comprehensive dailyByType correlation feed straight from the bucket means. Slope / R² / standard deviation / anomaly counts stay on live SQL — they do not compose across DAY buckets. A defensive parity check against the live aggregate's COUNT(*) falls back to live SQL when divergence is detected. Includes the new scripts/backfill-rollups.ts CLI for the one-shot fold of historical measurements into all four granularities. Tests: 4280 unit + 228 integration. tsc + lint clean. Migration 0067_v1434_measurement_rollups is idempotent under re-run.
This commit is contained in:
1 parent
9715831f82
commit
8dc9edb65e
26 files changed
+2650
-65
No files matched your search
@@ -25,6 +25,14 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findMany: vi.fn() },
|
||||
// v1.4.33 C1 — slim summaries slice runs through `$queryRaw`.
|
||||
$queryRaw: vi.fn(),
|
||||
// v1.4.35 — slim slice also reads DAY buckets; the freshness
|
||||
// watermark inside `ensureUserRollupsFresh` pokes `findFirst`.
|
||||
// Default both to empty so the parity check falls back to live
|
||||
// SQL and the slim slice assertions stay byte-shape stable.
|
||||
measurementRollup: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -100,6 +108,17 @@ beforeEach(() => {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
// v1.4.35 — rollup table defaults. `resetAllMocks` clears the
|
||||
// module-level implementations, so we re-seed both per test. Empty
|
||||
// findMany + null findFirst means the slim slice's parity check
|
||||
// diverges and falls back to live SQL (preserves the pre-v1.4.35
|
||||
// assertions in this file).
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
} from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||||
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -117,6 +118,15 @@ type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
* `buildCoachSnapshotImpl` shape.
|
||||
*/
|
||||
async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
// v1.5.0 — warm the persistent rollup table as a side effect of
|
||||
// the analytics fan-out. No-op when rollups are already ahead of
|
||||
// the newest measurement; on first cold-mount after a process
|
||||
// restart it folds the trailing 90-day window so downstream
|
||||
// consumers (Coach drawer, weekly report, admin analytics) see a
|
||||
// warm rollup table on their next read. The route's response
|
||||
// shape is untouched.
|
||||
await ensureUserRollupsFresh(user.id);
|
||||
|
||||
// v1.4.25 W7b — every day-bucket call inside this route now honours
|
||||
// the user's display timezone. The legacy `berlinDayKey()` import
|
||||
// remains for sleep-stage and correlation paths that share their
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { updateMeasurementSchema } from "@/lib/validations/measurement";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -112,6 +113,30 @@ export const PUT = apiHandler(
|
||||
// caches so subsequent reads reflect the edited row.
|
||||
invalidateUserMeasurements(user.id);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
return apiSuccess(measurement);
|
||||
},
|
||||
);
|
||||
@@ -150,6 +175,20 @@ export const DELETE = apiHandler(
|
||||
// caches so subsequent reads reflect the deletion.
|
||||
invalidateUserMeasurements(user.id);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -38,7 +38,11 @@ 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 { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
} from "@/lib/measurements/rollups";
|
||||
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
// v1.4.25 W16c — historical-backfill threshold for PR push
|
||||
// suppression. A batch larger than this fires the detection job with
|
||||
@@ -373,6 +377,31 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// gate the invalidation on `insertedCount > 0`.
|
||||
if (insertedCount > 0) {
|
||||
invalidateUserMeasurements(user.id);
|
||||
|
||||
// v1.5.0 — refresh the persistent rollup table for every distinct
|
||||
// (type, day) the batch touched. We use the `prepared` rows
|
||||
// because `createMany` does not return the inserted rows; the
|
||||
// (type, measuredAt) tuples on `prepared` are exactly the row
|
||||
// shape we need. Collapsed by day so a 500-row Apple Health
|
||||
// batch fires N recomputes (typically <30, one per type-day
|
||||
// pair) instead of one per row. Best-effort — a populator hiccup
|
||||
// never fails the user's ingest.
|
||||
try {
|
||||
const insertedKeys = collapseToTypeDayKeys(
|
||||
prepared.map((p) => ({
|
||||
type: p.row.type as MeasurementType,
|
||||
measuredAt:
|
||||
p.row.measuredAt instanceof Date
|
||||
? p.row.measuredAt
|
||||
: new Date(p.row.measuredAt as string),
|
||||
})),
|
||||
);
|
||||
for (const k of insertedKeys) {
|
||||
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
} from "@/lib/measurements/rollups";
|
||||
|
||||
const MAX_BATCH_ENTRIES = 500;
|
||||
|
||||
@@ -83,9 +87,18 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
|
||||
|
||||
// Prisma `deleteMany` with the userId predicate is the cross-user
|
||||
// 404 guard: rows belonging to other users are simply not matched.
|
||||
// No need for a pre-flight findMany — the unique index on
|
||||
// `(userId, type, source, externalId)` keeps the matching set small
|
||||
// even when the user has overlapping HealthKit history.
|
||||
// We pre-fetch the (type, measuredAt) tuples so the rollup recompute
|
||||
// step below knows which buckets to refresh after the deletion.
|
||||
// The unique index on `(userId, type, source, externalId)` keeps
|
||||
// the matched set small even with overlapping HealthKit history.
|
||||
const affectedRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
externalId: { in: externalIds },
|
||||
},
|
||||
select: { type: true, measuredAt: true },
|
||||
});
|
||||
|
||||
const result = await prisma.measurement.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -109,6 +122,19 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
|
||||
// reconciliation. A 0-delete batch is a no-op and skips the eviction.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ deletedCount: result.count });
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
} from "@/lib/measurements/rollups";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
MeasurementType,
|
||||
@@ -219,6 +223,23 @@ async function postMeasurement(request: NextRequest) {
|
||||
// measurement set so the next read paints the new rows.
|
||||
invalidateUserMeasurements(user.id);
|
||||
|
||||
// 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);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
|
||||
return apiSuccess(results, 201);
|
||||
}
|
||||
|
||||
@@ -279,5 +300,21 @@ async function postMeasurement(request: NextRequest) {
|
||||
// measurement set so the next read paints the new row.
|
||||
invalidateUserMeasurements(user.id);
|
||||
|
||||
// v1.5.0 — refresh the persistent rollup row for the affected
|
||||
// (type, day) tuple. Runs inline so the next read of the
|
||||
// comprehensive / analytics surface is correct without waiting on
|
||||
// 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);
|
||||
}
|
||||
|
||||
return apiSuccess(measurement, 201);
|
||||
}
|
||||
Reference in new issue
Block a user