diff --git a/src/app/api/measurements/[id]/__tests__/route.test.ts b/src/app/api/measurements/[id]/__tests__/route.test.ts index 97d7f7c7c..c7dfd55af 100644 --- a/src/app/api/measurements/[id]/__tests__/route.test.ts +++ b/src/app/api/measurements/[id]/__tests__/route.test.ts @@ -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), })); diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 1c425ce1c..e43e59801 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -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 }); }, diff --git a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts index b6858aad2..a46987f29 100644 --- a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts @@ -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 })), diff --git a/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts b/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts index 35300709e..b5e36298c 100644 --- a/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts +++ b/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts @@ -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 })), diff --git a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts index ea49626b6..9b551c5ac 100644 --- a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts +++ b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts @@ -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), })); diff --git a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts index 9ad2c0104..fa834286f 100644 --- a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts +++ b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts @@ -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 })), diff --git a/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts b/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts index b31590da7..5c79d949e 100644 --- a/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts +++ b/src/app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts @@ -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), diff --git a/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts b/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts index cc209dea2..e7369d833 100644 --- a/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts +++ b/src/app/api/measurements/batch/__tests__/unstable-external-id.test.ts @@ -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), diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 1199e2354..4d5b532bb 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -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 { // 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 diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index b70ab26de..3db8d1032 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -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 { // 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 }); diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 9914cb12b..82a419b61 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -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 { 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 }); diff --git a/src/app/api/measurements/restore/route.ts b/src/app/api/measurements/restore/route.ts index 8c2231473..d413ce609 100644 --- a/src/app/api/measurements/restore/route.ts +++ b/src/app/api/measurements/restore/route.ts @@ -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 { // 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 }); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 790c02efc..0856b77ef 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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); } diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index bd4253f9b..c89f61540 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -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 { }); 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 diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts index 09b6ebce6..98d63211a 100644 --- a/src/lib/insights/score-row.ts +++ b/src/lib/insights/score-row.ts @@ -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. */