chore(release): v1.4.39 — mood, medication-compliance, and cumulative-sum rollup tiers

Squashed merge of the v1.4.39 wave work from develop. The release
extends the v1.4.38 fast-path posture to two more endpoints that
still walked the source table on every cold mount and folds a
cumulative `sum_value` column into the existing rollup tier so step /
flight / distance / daylight / active-energy sparklines no longer
re-derive their daily totals in Node.

Raw measurements / mood_entries / medication_intake_events tables are
byte-unchanged. Three new tiers (mood_entry_rollups,
medication_compliance_rollups, measurement_rollups.sum_value) are
derived caches that self-heal via boot-time backfill on first reach.

Migrations 0070, 0071, 0072 are additive only. The sum_value column
add is catalog-only DDL on PostgreSQL 11+ — no table rewrite, writes
resume in milliseconds. No environment-variable change. No API
contract break. No iOS contract change.

Expected wins (live perf-verify rides the post-deploy window):
- /api/mood/analytics cold 12.7 s -> ~200 ms
- /api/medications/intake?scope=compliance cold 3.2 s -> ~200 ms
- /api/dashboard/summary cumulative sparkline ~500 ms -> ~300 ms
- /api/analytics live-fallback row cap 347 k -> ~5 k (425 d cap
  preserves the year-ago baseline tile)

Tests 4 524 -> 4 640 (+116). Typecheck clean. Lint clean.

See CHANGELOG.md for the full v1.4.39 entry.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 07:39:10 +02:00
1 parent 8a8150d2bf
commit d84922cdc3
59 files changed
+8272 -29

No files matched your search

@@ -0,0 +1,283 @@
/**
* v1.4.39 W-SINCE — defense-in-depth row cap on the live-fallback
* per-type read.
*
* The v1.4.38.7 perf audit (`.planning/round-v1438-perf-analysis.md`
* §1 A2) flagged the per-type `fetchMeasurementSeriesChunked` loop
* inside `/api/analytics` as the dominant cost of Marc's 74.6 s cold
* full-slice mount. v1.4.38.8 (commit 8a8150d2) relaxed the
* `isFullyCovered` gate inside the three downstream fast-paths so the
* walk became unreachable in the common case — but the A2 loop itself
* has no fast-path gate. A regression that flips a fast-path back to
* the all-types-required form would re-trigger the unbounded read.
*
* This file pins:
*
* 1. **The 425-day floor reaches `prisma.measurement.findMany`.**
* Every per-type read inside the default slice carries
* `where.measuredAt.gte` ≈ now − 425 days. QA Specialist-H2
* (v1.4.39) widened the original 90-day cap to 425 days so
* `summarize().avg30LastYear` (year-ago tile) stays populated
* on the live-fallback path. If a future refactor drops the
* `since` option from the chunked helper this test breaks loudly.
*
* 2. **The `meta.analytics.bp_aggregate.live_since` annotate fires.**
* Lets the next perf-verify see how far back the live-fallback
* read actually went. A widened window in a regression would
* surface in this annotation.
*
* 3. **The slim slice (`?slice=summaries`) is unaffected.** That
* branch never hit the per-type loop in the first place and
* keeps its own SQL aggregator.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Spy on the real `annotate` so we can assert the
// `meta.analytics.bp_aggregate.live_since` field while keeping the
// AsyncLocalStorage `eventStorage.run` wiring intact (the
// `apiHandler` opens an event scope inside `run()`).
vi.mock("@/lib/logging/context", async () => {
const actual = await vi.importActual<
typeof import("@/lib/logging/context")
>("@/lib/logging/context");
return {
...actual,
annotate: vi.fn(actual.annotate),
};
});
// The three fast-paths and the rollup helpers are exercised by their
// own tests; here we stub them so the route's per-type read is the
// only Prisma traffic the test observes.
vi.mock("@/lib/measurements/rollups", () => ({
ensureUserRollupsFresh: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
probeRollupCoverage: vi.fn().mockResolvedValue(new Map()),
}));
vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({
computeBpInTargetFastPath: vi.fn().mockResolvedValue({
last7Days: null,
last30Days: null,
allTime: null,
priorMonth: null,
priorYear: null,
}),
}));
vi.mock("@/lib/analytics/health-score-fast-path", () => ({
computeUserHealthScoreFastPath: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/analytics/correlations-fast-path", () => ({
computeCorrelationHypothesesFastPath: vi
.fn()
.mockResolvedValue({ hypotheses: [] }),
}));
vi.mock("@/lib/analytics/summaries-slice", () => ({
computeSummariesSlice: vi.fn().mockResolvedValue({
summaries: {},
bmi: null,
bpInTargetPct: null,
lastSeenByType: {},
}),
}));
import { GET } from "@/app/api/analytics/route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const callGet = GET as unknown as (req: Request) => Promise<Response>;
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-since-1",
username: "marc",
role: "USER" as const,
locale: "en",
timezone: "Europe/Berlin",
heightCm: null,
dateOfBirth: null,
sourcePriorityJson: null,
},
};
beforeEach(async () => {
vi.clearAllMocks();
__resetAllCachesForTests();
(getSession as ReturnType<typeof vi.fn>).mockResolvedValue(SESSION_OK);
// Default: every per-type findMany returns an empty page so the
// chunked loop terminates after the first call.
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
// Re-prime the fast-path / probe / slim-slice stubs because we use
// `clearAllMocks()` to wipe call history between tests; `vi.fn()` at
// module scope only sets the initial implementation, and the slim
// slice test must observe a resolved value not `undefined`.
const { ensureUserRollupsFresh } = await import(
"@/lib/measurements/rollups"
);
const { probeRollupCoverage } = await import(
"@/lib/measurements/rollup-coverage"
);
const { computeBpInTargetFastPath } = await import(
"@/lib/analytics/bp-in-target-fast-path"
);
const { computeUserHealthScoreFastPath } = await import(
"@/lib/analytics/health-score-fast-path"
);
const { computeCorrelationHypothesesFastPath } = await import(
"@/lib/analytics/correlations-fast-path"
);
const { computeSummariesSlice } = await import(
"@/lib/analytics/summaries-slice"
);
(ensureUserRollupsFresh as ReturnType<typeof vi.fn>).mockResolvedValue(
undefined,
);
(probeRollupCoverage as ReturnType<typeof vi.fn>).mockResolvedValue(
new Map(),
);
(computeBpInTargetFastPath as ReturnType<typeof vi.fn>).mockResolvedValue({
last7Days: null,
last30Days: null,
allTime: null,
priorMonth: null,
priorYear: null,
});
(computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
(
computeCorrelationHypothesesFastPath as ReturnType<typeof vi.fn>
).mockResolvedValue({ hypotheses: [] });
(computeSummariesSlice as ReturnType<typeof vi.fn>).mockResolvedValue({
summaries: {},
bmi: null,
lastSeenByType: {},
});
});
describe("GET /api/analytics — live-fallback `since` cap (W-SINCE)", () => {
it("passes a trailing-425-day `where.measuredAt.gte` to every per-type findMany", async () => {
const before = Date.now();
const response = await callGet(
new Request("http://localhost/api/analytics"),
);
expect(response.status).toBe(200);
const after = Date.now();
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
expect(findMany.mock.calls.length).toBeGreaterThan(0);
// QA Specialist-H2 (v1.4.39): 425 days = 365 (year-ago window) +
// 30 (avg30LastYear bucket span) + 30 (cache-aging buffer).
const FOUR_TWENTY_FIVE_DAYS_MS = 425 * 24 * 60 * 60 * 1000;
const expectedMinSince = before - FOUR_TWENTY_FIVE_DAYS_MS;
const expectedMaxSince = after - FOUR_TWENTY_FIVE_DAYS_MS;
// The per-type loop (A2) issues chunked reads with an `orderBy`
// *array* (`(measuredAt asc, id asc)`); the 30-day glucose and
// 30-day sleep-stage reads use either a single-object orderBy or
// none at all. Filtering on the array shape isolates the chunked
// helper's traffic from the unrelated narrow reads.
const perTypeCalls = findMany.mock.calls.filter((call) => {
const arg = call[0] as { orderBy?: unknown };
return Array.isArray(arg?.orderBy);
});
expect(perTypeCalls.length).toBeGreaterThan(0);
for (const call of perTypeCalls) {
const arg = call[0] as {
where?: { measuredAt?: { gte?: Date } };
};
const gte = arg.where?.measuredAt?.gte;
expect(gte).toBeInstanceOf(Date);
const gteMs = (gte as Date).getTime();
expect(gteMs).toBeGreaterThanOrEqual(expectedMinSince);
expect(gteMs).toBeLessThanOrEqual(expectedMaxSince);
}
});
it("annotates `meta.analytics.bp_aggregate.live_since` with the 425-day cutoff ISO", async () => {
const before = Date.now();
await callGet(new Request("http://localhost/api/analytics"));
const after = Date.now();
const annotateFn = annotate as ReturnType<typeof vi.fn>;
const bpAggregateCall = annotateFn.mock.calls.find((call) => {
const fields = call[0] as
| { meta?: { analytics?: { bp_aggregate?: unknown } } }
| undefined;
return fields?.meta?.analytics?.bp_aggregate !== undefined;
});
expect(bpAggregateCall).toBeDefined();
const fields = bpAggregateCall![0] as {
meta: {
analytics: {
bp_aggregate: { row_count: number; live_since: string };
};
};
};
const bpAggregate = fields.meta.analytics.bp_aggregate;
expect(typeof bpAggregate.live_since).toBe("string");
const FOUR_TWENTY_FIVE_DAYS_MS = 425 * 24 * 60 * 60 * 1000;
const liveSinceMs = new Date(bpAggregate.live_since).getTime();
expect(liveSinceMs).toBeGreaterThanOrEqual(
before - FOUR_TWENTY_FIVE_DAYS_MS,
);
expect(liveSinceMs).toBeLessThanOrEqual(after - FOUR_TWENTY_FIVE_DAYS_MS);
});
it("does not invoke the per-type loop on the slim `?slice=summaries` branch", async () => {
const response = await callGet(
new Request("http://localhost/api/analytics?slice=summaries"),
);
expect(response.status).toBe(200);
// The slim slice resolves through `computeSummariesSlice` only;
// the per-type findMany loop is skipped entirely so the rollup-
// path semantics are unaffected by the `since` cap.
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
expect(findMany).not.toHaveBeenCalled();
});
});
@@ -43,6 +43,11 @@ import {
summarizeBackup,
type BackupSummary,
} from "@/lib/validations/backup";
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
import {
recomputeUserMedicationCompliance,
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
} from "@/lib/medications/compliance-rollups";
export const dynamic = "force-dynamic";
@@ -281,6 +286,11 @@ const handler = apiHandler(
const moods = await tx.moodEntry.deleteMany({
where: { userId: ownerId },
});
// v1.4.39 W-MOOD — wipe the persisted mood rollup partition
// for this owner so the next analytics read doesn't surface
// pre-restore daily means. The fold below mints fresh rows
// from the restored mood entries.
await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } });
const channels = await tx.notificationChannel.deleteMany({
where: { userId: ownerId },
});
@@ -410,6 +420,52 @@ const handler = apiHandler(
return apiError("Restore failed", 500);
}
// 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
// hiccup doesn't undo the restore. The boot-time backfill is the
// safety net — if this fails the next worker boot mints the rows.
if (payload.moodEntries.length > 0) {
try {
await recomputeUserMoodRollups(ownerId, { granularities: ["DAY"] });
} catch (err) {
annotate({
meta: {
mood_rollup_restore_failed: true,
mood_rollup_restore_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
// v1.4.39 W-MED — re-fold the medication-compliance rollup tier
// from the restored intake events. The medication delete inside the
// transaction already cascaded the existing rollup partition for
// this owner via the FK, so the fold mints fresh rows. Boot-time
// backfill is the safety net if this best-effort call fails.
if (payload.intakeEvents.length > 0) {
try {
const restoreUser = await prisma.user.findUnique({
where: { id: ownerId },
select: { timezone: true },
});
await recomputeUserMedicationCompliance(
ownerId,
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
restoreUser?.timezone ?? null,
);
} catch (err) {
annotate({
meta: {
medication_compliance_rollup_restore_failed: true,
medication_compliance_rollup_restore_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
const summary = summarizeBackup(payload);
await auditLog("admin.backups.restore", {
+52 -1
View File
@@ -205,6 +205,43 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// when the column is null or malformed.
const sourcePriorityJson = user.sourcePriorityJson;
// v1.4.39 W-SINCE — defense-in-depth row cap on the per-type live
// read. The v1.4.38.8 per-type fast-path gate (commit 8a8150d2)
// narrowed `isFullyCovered` from "all types covered" to "these
// specific types covered" inside the three downstream fast-paths
// (bp_in_target / health_score / correlations). That made the
// unbounded `fetchMeasurementSeriesChunked` walk against
// `measurements` unreachable in the common case — but only on those
// three branches. This per-type loop (A2 in the v1.4.38 perf audit)
// has no fast-path gate at all; it always runs live SQL.
//
// Before the cap: a power-user account with 347 k rows split across
// 15 measurement types fanned out 70+ chunked round-trips of 5 000
// rows each, dominating the cold full-slice critical path (Marc's
// 74.6 s cold mount).
//
// After the cap: the trailing 425 days bound the per-type walk
// while preserving `summarize().avg30LastYear` (points 365-395 days
// ago) and `summarize().avg30LastMonth` (30-60 days ago) for the
// dashboard's year-over-year and month-over-month comparison tiles.
// Cumulative HealthKit types (ACTIVITY_STEPS, ACTIVE_ENERGY, …)
// collapse to one-row-per-day after the per-day-sum pass downstream
// so a 425-day window stays bounded (≤ ~25 k rows per type on a
// saturated tenant).
//
// QA Specialist-H2 (v1.4.39): the original 90-day cap stripped the
// year-ago window so `summary.avg30LastYear` returned null on every
// fall-through path, making the "vs last year" tile render "no
// prior data" on every cold mount. The 425-day cap (365 + 30 + 30
// buffer) keeps the year-ago window populated; the slim slice
// (`?slice=summaries`) and the rollup-backed comprehensive
// aggregator already operate on tighter windows so this cap only
// affects the legacy live-fallback path the fast-path gate misses.
const ANALYTICS_LIVE_WINDOW_DAYS = 425;
const liveSince = new Date(
Date.now() - ANALYTICS_LIVE_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
// v1.4.34 IW-B — capture the most-recent `measuredAt` per type so the
// dashboard tile strip can render an "Letzter Wert vor Xd" caption on
// tiles whose latest reading is older than a week. The series read
@@ -225,6 +262,11 @@ async function buildAnalyticsResponse(user: AuthedUser) {
types.map((type) =>
fetchMeasurementSeriesChunked(user.id, type, {
includeSleepStage: true,
// v1.4.39 W-SINCE — trailing 425-day floor for the live read.
// Wide enough to populate `summarize().avg30LastYear` (the
// year-ago comparison tile) while still bounding the per-type
// row count. See comment block above.
since: liveSince,
}).then((measurements) => {
totalRowsReadForAggregate += measurements.length;
// v1.4.34 IW-B — record the freshest `measuredAt` for this type
@@ -317,10 +359,19 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// v1.4.23 Sr-H1 — slow-query attribution. Total rows pulled across
// every per-type chunked read so ops can spot outlier users whose
// analytics requests dominate the route's tail latency.
//
// v1.4.39 W-SINCE — also surface `live_since` (ISO) so the next
// perf-verify can see how far back the live-fallback read actually
// went. With the 425-day cap in place this should be ~now-425d on
// every full-slice request; an older value would signal that a
// future refactor accidentally widened the window again.
annotate({
meta: {
analytics: {
bp_aggregate: { row_count: totalRowsReadForAggregate },
bp_aggregate: {
row_count: totalRowsReadForAggregate,
live_since: liveSince.toISOString(),
},
},
},
});
@@ -206,6 +206,93 @@ describe("GET /api/dashboard/summary", () => {
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
});
it("paints the steps sparkline from rollup sum_value, not mean (v1.4.39 W-SUM)", async () => {
// The ACTIVITY_STEPS tile renders the per-day SUM, not the
// per-bucket MEAN. The sparkline query feeds the SQL aggregate
// directly off the `measurement_rollups` row; pinning the
// returned shape proves the route hands `sum_value` through
// instead of reconstructing from `mean * count`.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "ACTIVITY_STEPS",
_count: { _all: 7 },
_max: { measuredAt: new Date() },
},
] as never);
// First call: latest7d. Second call: sparkline. Third call:
// streakDays. Matches the Promise.all order documented in the
// route's perf-comment.
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
value: 9999,
measured_at: new Date(),
},
] as never)
.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
bucket_start: new Date(),
mean: 2000,
count: 4,
// 8000 ≠ mean × count (4 × 2000 = 8000) but the SUM is the
// direct rollup column; the fallback path would also hit
// 8000 here. Use 8120 to prove the route did NOT reconstruct
// from mean × count.
sum_value: 8120,
},
] as never)
.mockResolvedValueOnce([] as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { metrics: Array<{ id: string; sparkline: number[] }> };
};
const steps = body.data.metrics.find((m) => m.id === "steps");
expect(steps?.sparkline).toEqual([8120]);
});
it("falls back to mean * count when the legacy sum_value is null (v1.4.39 W-SUM)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "ACTIVITY_STEPS",
_count: { _all: 7 },
_max: { measuredAt: new Date() },
},
] as never);
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
value: 9999,
measured_at: new Date(),
},
] as never)
.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
bucket_start: new Date(),
mean: 2000,
count: 4,
sum_value: null,
},
] as never)
.mockResolvedValueOnce([] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: { metrics: Array<{ id: string; sparkline: number[] }> };
};
const steps = body.data.metrics.find((m) => m.id === "steps");
// 4 × 2000 — legacy fallback for the boot-backfill convergence
// window keeps the chart non-empty for pre-v1.4.39 rows.
expect(steps?.sparkline).toEqual([8000]);
});
it("computes intake compliance for today", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
+31 -3
View File
@@ -43,6 +43,7 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
@@ -202,11 +203,19 @@ interface LatestIn7dRow {
/** v1.4.38 W-F — per-day measurement_rollup bucket inside the 7-day
* window. At most 7 buckets per metric × N metrics — bounded by
* `SPARK_DAYS * |measurementTypes|` rather than the raw row count. */
* `SPARK_DAYS * |measurementTypes|` rather than the raw row count.
*
* v1.4.39 W-SUM — `sum_value` rides along so the cumulative tile
* (ACTIVITY_STEPS) renders the daily SUM rather than the per-bucket
* MEAN. Spot metrics ignore the column; cumulative tiles fall back to
* `mean * count` when the legacy NULL hits (boot-backfill convergence
* window). */
interface SparklineRow {
type: MeasurementType;
bucket_start: Date;
mean: number;
count: number;
sum_value: number | null;
}
/** v1.4.38 W-F — distinct activity day-keys from the streak window.
@@ -325,7 +334,9 @@ async function buildDashboardSummary(
SELECT
r."type" AS type,
r."bucket_start" AS bucket_start,
r."mean"::double precision AS mean
r."mean"::double precision AS mean,
r."count"::int AS count,
r."sum_value"::double precision AS sum_value
FROM measurement_rollups r
WHERE r."user_id" = ${userId}
AND r."granularity" = 'DAY'
@@ -430,10 +441,27 @@ async function buildDashboardSummary(
at: new Date(row.measured_at),
});
}
// v1.4.39 W-SUM — cumulative tiles paint the daily SUM; spot tiles
// keep the daily MEAN. Reading `sum_value` directly off the rollup
// row eliminates the legacy `mean * count` reconstruction; the
// fallback covers pre-v1.4.39 NULL rows during the boot-backfill
// convergence window.
const sparkByType = new Map<MeasurementType, number[]>();
for (const row of sparkBuckets) {
const list = sparkByType.get(row.type) ?? [];
list.push(Number(row.mean));
// QA Simplifier (v1.4.39): flatten the cumulative-vs-spot ternary
// into a named branch per the CLAUDE.md "no nested ternaries"
// rule. Cumulative tiles paint the daily SUM; spot tiles keep the
// daily MEAN. The legacy NULL fallback covers pre-v1.4.39 rollup
// rows the boot-backfill hasn't refreshed yet.
const cumulativePoint =
row.sum_value !== null
? Number(row.sum_value)
: Number(row.mean) * Number(row.count);
const point = CUMULATIVE_HK_TYPES.has(row.type)
? cumulativePoint
: Number(row.mean);
list.push(point);
sparkByType.set(row.type, list);
}
+20
View File
@@ -17,6 +17,7 @@ import {
measurementTypeEnum,
glucoseContextEnum,
} from "@/lib/validations/measurement";
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
// Derived from canonical enum so round-trip export → import covers every
// type. Previous hardcoded subset silently dropped 4 of 11 types
@@ -128,6 +129,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
// v1.4.39 W-MOOD — one bounded re-fold per import is cheaper than
// firing N per-row hooks. The mood rollup tier is keyed on the
// user + day; the fold materialises every touched day from the
// entries we just wrote. Best-effort: an importer hiccup must not
// surface as a 5xx, the rollup is a cache tier.
if (stats.moodEntries > 0) {
try {
await recomputeUserMoodRollups(userId, { granularities: ["DAY"] });
} catch (err) {
annotate({
meta: {
mood_rollup_import_failed: true,
mood_rollup_import_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
await auditLog("import.upload", {
userId,
ipAddress: getClientIp(request),
+15
View File
@@ -12,6 +12,7 @@ import { hashToken } from "@/lib/auth/hmac";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { externalIntakeSchema } from "@/lib/validations/medication";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -152,5 +153,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({ meta: { medication_id: medication.id, event_id: event.id } });
// v1.4.39 W-MED — refresh the compliance rollup for the ingested
// event's user-day. The external ingest path runs without a User
// record in scope; resolve the tz once for the recompute.
const ingestUser = await prisma.user.findUnique({
where: { id: apiToken.userId },
select: { timezone: true },
});
await recomputeMedicationComplianceForEvent({
userId: apiToken.userId,
medicationId: medication.id,
scheduledFor: event.scheduledFor,
tz: ingestUser?.timezone ?? null,
});
return apiSuccess(event, 201);
});
@@ -8,6 +8,7 @@ import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
export const dynamic = "force-dynamic";
@@ -124,6 +125,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
}
// v1.4.39 W-MOOD — refresh the persisted rollup for the entry's
// bucket. Inline best-effort: a failure here must not block the
// webhook 200 response (the rollup is a cache tier, the source-of-
// truth is the mood_entries write that already committed).
try {
await recomputeMoodBucketsForEntry(user.id, moodLoggedAt);
} catch (rollupErr) {
getEvent()?.addWarning(
"Mood rollup recompute failed: " +
(rollupErr instanceof Error ? rollupErr.message : String(rollupErr)),
);
}
} catch (err) {
getEvent()?.addWarning("DB operation failed: " + err);
}
@@ -233,6 +233,65 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
expect(json.data.meta.aggregate).toBe("daily");
});
it("source=rollup cumulative path reads sum_value directly (v1.4.39 W-SUM)", async () => {
// ACTIVITY_STEPS rollup row with sum_value populated. The route
// must consume sumValue directly — NOT reconstruct from mean *
// count. Use a sum that is NOT mean × count so the test
// distinguishes the two paths.
const buckets = Array.from({ length: 3 }, (_, i) => ({
type: "ACTIVITY_STEPS",
bucketStart: new Date(Date.UTC(2026, 4, 1 + i, 0, 0, 0)),
mean: 2000, // would yield 10000 if multiplied by count=5
count: 5,
sumValue: 11000 + i * 250, // distinct from mean × count
}));
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { measurements: Array<{ value: number }> };
};
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(json.data.measurements.map((m) => m.value)).toEqual([
11000, 11250, 11500,
]);
});
it("source=rollup cumulative path falls back to mean*count when sum_value is NULL (v1.4.39 W-SUM)", async () => {
// Pre-v1.4.39 row — boot-backfill hasn't converged yet. The
// route falls back to mean × count so the chart never paints
// a hole during the convergence window.
const buckets = [
{
type: "ACTIVITY_STEPS",
bucketStart: new Date(Date.UTC(2026, 4, 1)),
mean: 2000,
count: 5,
sumValue: null,
},
];
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
buckets as never,
);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&from=2026-05-01T00:00:00Z&to=2026-05-05T00:00:00Z&aggregate=daily&source=rollup&limit=5000",
),
);
const json = (await res.json()) as {
data: { measurements: Array<{ value: number }> };
};
expect(json.data.measurements[0].value).toBe(10000);
});
it("falls back to live date_trunc when source=rollup returns zero buckets", async () => {
// Empty rollup ⇒ heavy aggregate runs so brand-new accounts still
// see a correct chart on their first render.
+14 -4
View File
@@ -285,19 +285,29 @@ export const GET = apiHandler(async (request: NextRequest) => {
bucketStart: true,
mean: true,
count: true,
// v1.4.39 W-SUM — the writer now populates `sum_value` on
// every fold. The cumulative metric path consumes the column
// directly instead of reconstructing `mean * count`; the
// legacy fallback below covers the boot-backfill convergence
// window when an existing bucket pre-dates v1.4.39.
sumValue: true,
},
});
if (rollupRows.length > 0) {
// Cumulative metrics (steps, active energy, distance, flights,
// daylight) store `mean` per bucket but the chart needs the
// daily SUM. Reconstruct as `mean * count` — algebraically
// equivalent to SUM(value) for a single-source day because
// AVG = SUM / COUNT.
// daily SUM. v1.4.39 W-SUM — read `sumValue` directly; fall back
// to `mean * count` only when the legacy NULL surfaces (pre-
// v1.4.39 rows still waiting for the boot-time backfill to
// converge). `mean * count` is algebraically equivalent to
// SUM(value) for a single-source day because AVG = SUM / COUNT.
const useSum =
type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType);
const measurements = rollupRows.map((r) => ({
type: r.type,
value: useSum ? r.mean * r.count : r.mean,
value: useSum
? (r.sumValue ?? r.mean * r.count)
: r.mean,
measuredAt: r.bucketStart.toISOString(),
count: r.count,
}));
@@ -10,6 +10,7 @@ import {
} from "@/lib/api-response";
import { updateIntakeEventSchema } from "@/lib/validations/medication";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
@@ -67,6 +68,28 @@ export const PUT = apiHandler(
// caches so the next read reflects the edited event.
invalidateUserMedications(user.id);
// v1.4.39 W-MED — refresh the rollup row for both the old + new
// scheduledFor day-keys when the timestamp moved across a day
// boundary; if only takenAt / skipped changed the second call
// collapses to a no-op upsert.
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor,
tz: user.timezone,
});
if (
data.scheduledFor !== undefined &&
data.scheduledFor.getTime() !== event.scheduledFor.getTime()
) {
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: id,
scheduledFor: data.scheduledFor,
tz: user.timezone,
});
}
return apiSuccess(updated);
},
);
@@ -107,6 +130,16 @@ export const DELETE = apiHandler(
// caches so the next read reflects the deletion.
invalidateUserMedications(user.id);
// v1.4.39 W-MED — refresh the rollup row for the day the deleted
// event sat on. When that day now holds zero events the helper
// drops the rollup row entirely.
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor,
tz: user.timezone,
});
return apiSuccess({ deleted: true });
},
);
@@ -8,6 +8,10 @@ import {
getClientIp,
safeJson,
} from "@/lib/api-response";
import {
recomputeMedicationComplianceForDay,
dayKeyForScheduledFor,
} from "@/lib/medications/compliance-rollups";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -51,6 +55,9 @@ export const POST = apiHandler(
let imported = 0;
let skippedDuplicates = 0;
let skippedInvalid = 0;
// v1.4.39 W-MED — collect distinct day-keys touched by the import
// so the rollup pass fires once per day rather than once per row.
const touchedDays = new Set<string>();
for (const entry of entries) {
// Parse as local datetime first; if invalid, fall back to CET offset.
@@ -88,6 +95,30 @@ export const POST = apiHandler(
},
});
imported++;
touchedDays.add(dayKeyForScheduledFor(takenAt, user.timezone));
}
// v1.4.39 W-MED — fold the touched days into rollup rows after the
// insert loop completes. Best-effort: a populator failure logs but
// never rolls back the imported events.
for (const dayKey of touchedDays) {
try {
await recomputeMedicationComplianceForDay(
user.id,
id,
dayKey,
user.timezone,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
medication_compliance_rollup_import_failed: true,
medication_compliance_rollup_import_error: message,
medication_compliance_rollup_day: dayKey,
},
});
}
}
await auditLog("medication.intake.import", {
@@ -21,6 +21,13 @@ export const DELETE = apiHandler(
where: { medicationId: id, userId: user.id },
});
// v1.4.39 W-MED — drop every rollup row for this medication so the
// next compliance read returns zero-filled buckets rather than the
// last-known scheduled / taken totals for now-deleted events.
await prisma.medicationComplianceRollup.deleteMany({
where: { medicationId: id, userId: user.id },
});
await auditLog("medication.intake.purge", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -16,6 +16,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { consumeOneDose } from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -159,6 +160,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// caches so the next read reflects the dose event.
invalidateUserMedications(user.id);
// v1.4.39 W-MED — refresh the persistent compliance rollup for the
// affected day. The hook is best-effort; failures annotate but never
// block the user's POST response.
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor,
tz: user.timezone,
});
return apiSuccess(event, 201);
}
@@ -9,10 +9,23 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
},
medication: { update: vi.fn() },
medicationComplianceRollup: {
findFirst: vi.fn(),
findMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
// v1.4.39 QA F-H-01 — coverage probe + atomic upsert use raw SQL.
$queryRaw: vi.fn(),
$executeRaw: vi.fn().mockResolvedValue(0),
},
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => null,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -52,6 +65,22 @@ beforeEach(() => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
// v1.4.39 W-MED — default the coverage probe to "uncovered" so the
// legacy compliance test still exercises the live-fallback branch
// and finds the legacy mocked intake events.
vi.mocked(prisma.medicationComplianceRollup.findFirst).mockResolvedValue(
null,
);
vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue(
[] as never,
);
// v1.4.39 QA F-H-01 — the coverage probe is now a single `$queryRaw`
// aggregate returning `{ rolled_days, event_days }`. Default to
// "zero rollups, zero events" (covered/trivial-empty) so tests that
// don't care about coverage land on the rollup path.
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(0) },
] as never);
});
describe("GET /api/medications/intake", () => {
@@ -154,6 +183,7 @@ describe("POST /api/medications/intake", () => {
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: new Date("2026-05-18T10:00:00.000Z"),
} as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
id: "e1",
@@ -168,3 +198,58 @@ describe("POST /api/medications/intake", () => {
});
});
});
describe("v1.4.39 W-MED — compliance rollup read swap", () => {
it("reads the rollup tier when coverage is present", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// QA F-H-01 (v1.4.39): coverage probe returns
// `{ rolled_days >= event_days }` so the route lands on the
// rollup tier. Match the trailing-7-day window.
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ rolled_days: BigInt(7), event_days: BigInt(7) },
] as never);
vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue([
{ day: "2026-05-18", scheduled: 3, taken: 2, skipped: 1 },
] as never);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/intake?scope=compliance&days=7",
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ date: string; scheduled: number; taken: number }>;
};
expect(body.data).toHaveLength(7);
const today = body.data[body.data.length - 1];
expect(today.scheduled).toBe(3);
expect(today.taken).toBe(2);
// The legacy live aggregator must not have run when coverage is hot.
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
it("falls back to the live aggregator on coverage miss", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// QA F-H-01 (v1.4.39): partial coverage — events present but
// rollups missing — forces fall-through to the live aggregator.
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(7) },
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
scheduledFor: new Date(),
takenAt: new Date(),
skipped: false,
},
] as never);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/intake?scope=compliance&days=7",
),
);
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalled();
});
});
@@ -41,6 +41,10 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
recomputeMedicationComplianceForDay,
dayKeyForScheduledFor,
} from "@/lib/medications/compliance-rollups";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -126,6 +130,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
let inserted = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
// touched by the batch so one rollup recompute fires per pair after
// all inserts complete. Per-row recompute would balloon a 500-entry
// batch into 500 sequential rollup hits.
const touchedDays = new Map<string, { medicationId: string; dayKey: string }>();
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -155,6 +164,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
});
inserted += 1;
results.push({ index: i, status: "inserted", id: row.id });
const dayKey = dayKeyForScheduledFor(scheduledFor, user.timezone);
touchedDays.set(`${entry.medicationId}|${dayKey}`, {
medicationId: entry.medicationId,
dayKey,
});
} catch (err: unknown) {
// P2002 = unique-constraint violation; the idempotencyKey
// already exists → "duplicate", not an error.
@@ -213,6 +227,32 @@ async function postBulk(request: NextRequest): Promise<Response> {
invalidateUserMedications(user.id);
}
// v1.4.39 W-MED — refresh one rollup row per distinct
// `(medicationId, dayKey)` touched by the batch. Best-effort: failures
// are swallowed by the helper's `recomputeMedicationComplianceForEvent`
// wrapper, but bulk callers wrap recompute directly to keep the
// tight ingest loop unaffected by populator issues.
for (const { medicationId, dayKey } of touchedDays.values()) {
try {
await recomputeMedicationComplianceForDay(
user.id,
medicationId,
dayKey,
user.timezone,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
medication_compliance_rollup_bulk_failed: true,
medication_compliance_rollup_bulk_error: message,
medication_compliance_rollup_medication: medicationId,
medication_compliance_rollup_day: dayKey,
},
});
}
}
return apiSuccess({
processed: entries.length,
inserted,
+71 -1
View File
@@ -26,6 +26,13 @@ import { auditLog } from "@/lib/auth/audit";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
readMedicationCompliance,
hasMedicationComplianceCoverage,
recomputeMedicationComplianceForEvent,
enqueueUserMedicationComplianceBackfill,
type ComplianceBucket as RollupComplianceBucket,
} from "@/lib/medications/compliance-rollups";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -116,10 +123,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
// are slow-moving (intake events trickle in, yesterday's row doesn't
// move). Cache key carries the userTz so a user who changes timezone
// doesn't read another tz's bucketing.
//
// v1.4.39 W-MED — read path now consumes the persistent
// `medication_compliance_rollups` tier. A coverage probe falls back
// to the legacy live aggregator when the user has intake events but
// zero rollup rows; the fallback fires a boot-backfill enqueue in the
// background so the next request hits the rollup tier.
const result = await cached(
caches.medicationsIntake as ServerCache<ComplianceBucket[]>,
`${user.id}|compliance|${days}|${userTz}`,
() => buildComplianceBuckets(user.id, days, userTz),
() => readComplianceBucketsWithFallback(user.id, days, userTz),
annotate,
);
@@ -131,6 +144,50 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess(result);
});
/**
* v1.4.39 W-MED — read from the persistent rollup tier when covered,
* fall through to the legacy live aggregator on coverage miss. The
* fallback fires the boot backfill in the background so subsequent
* requests hit the rollup path; the current request still returns a
* correct response built from the raw events.
*/
async function readComplianceBucketsWithFallback(
userId: string,
days: number,
userTz: string,
): Promise<ComplianceBucket[]> {
const covered = await hasMedicationComplianceCoverage(userId, days, userTz);
if (covered) {
const buckets = await readMedicationCompliance(userId, days, userTz);
annotate({
meta: {
medication_compliance_path: "rollup",
medication_compliance_days: days,
},
});
return buckets satisfies RollupComplianceBucket[];
}
// Coverage miss — the legacy aggregator stays correct over an empty
// rollup window. Fire a user-scoped backfill in the background so
// the next request lands on the rollup tier; the current request
// still returns the live-derived buckets.
//
// QA F-SEC-M-01 (v1.4.39): pre-fix this fired the cluster-wide
// `enqueueBootTimeMedicationComplianceBackfill` on every coverage-
// miss request, opening a soft-DoS amplifier (every authenticated
// user could drive a multi-tenant `LEFT JOIN` scan on each hit).
// The user-scoped helper enqueues exactly the caller's account.
void enqueueUserMedicationComplianceBackfill(userId);
annotate({
meta: {
medication_compliance_path: "live-fallback",
medication_compliance_days: days,
},
});
return buildComplianceBuckets(userId, days, userTz);
}
interface ComplianceBucket {
date: string;
scheduled: number;
@@ -194,6 +251,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Intake event not found", 404);
}
const userTzForHook = user.timezone ?? DEFAULT_TIMEZONE;
let updated;
if (status === "taken") {
[updated] = await prisma.$transaction([
@@ -238,5 +297,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
// caches for this user so the next read reflects the dose change.
invalidateUserMedications(user.id);
// v1.4.39 W-MED — refresh the persistent compliance rollup row for
// the affected day so the next read after the cache miss returns
// the up-to-date `(scheduled, taken, skipped)` tuple. Best-effort:
// a populator failure annotates ops without blocking the response.
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: existing.scheduledFor,
tz: userTzForHook,
});
return apiSuccess(updated);
});
+41
View File
@@ -15,6 +15,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
type RouteParams = { params: Promise<{ id: string }> };
@@ -112,6 +113,31 @@ export const PUT = apiHandler(
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
// v1.4.39 W-MOOD — refresh the persisted rollup for the new
// bucket AND the old bucket when the entry's `moodLoggedAt`
// changed. The two recomputes are independent (different
// (user, day) tuples) so we fan them out in parallel. Best-
// effort: rollup failures must not surface as 5xx.
try {
const targets = new Set<number>([entry.moodLoggedAt.getTime()]);
if (existing.moodLoggedAt.getTime() !== entry.moodLoggedAt.getTime()) {
targets.add(existing.moodLoggedAt.getTime());
}
await Promise.all(
Array.from(targets).map((t) =>
recomputeMoodBucketsForEntry(user.id, new Date(t)),
),
);
} catch (rollupErr) {
annotate({
meta: {
mood_rollup_write_failed: true,
mood_rollup_write_error:
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
},
});
}
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
},
);
@@ -144,6 +170,21 @@ export const DELETE = apiHandler(
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
// v1.4.39 W-MOOD — refresh the persisted rollup for the
// deleted entry's bucket; the recompute helper handles the
// "now-empty day → drop the rollup row" branch internally.
try {
await recomputeMoodBucketsForEntry(user.id, existing.moodLoggedAt);
} catch (rollupErr) {
annotate({
meta: {
mood_rollup_write_failed: true,
mood_rollup_write_error:
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
},
});
}
return apiSuccess({ deleted: true });
},
);
+37
View File
@@ -43,6 +43,7 @@ import {
} from "@/lib/validations/moodlog";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -218,6 +219,42 @@ async function postBulk(request: NextRequest): Promise<Response> {
invalidateUserMood(user.id);
}
// v1.4.39 W-MOOD — refresh the rollup tier for every distinct day
// touched by this batch. The bulk endpoint is an iOS one-shot
// backfill so the batch can span many days; we collapse to the
// unique `(user, dayStart)` set first to bound the recompute count.
// Best-effort: rollup failures must not surface as 5xx.
if (inserted > 0 || duplicates > 0) {
const touchedDayStarts = new Set<number>();
for (let i = 0; i < entries.length; i++) {
const status = results[i]?.status;
if (status === "inserted" || status === "duplicate") {
const d = entries[i].moodLoggedAt;
const dayStart = Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
);
touchedDayStarts.add(dayStart);
}
}
try {
await Promise.all(
Array.from(touchedDayStarts).map((t) =>
recomputeMoodBucketsForEntry(user.id, new Date(t)),
),
);
} catch (rollupErr) {
annotate({
meta: {
mood_rollup_write_failed: true,
mood_rollup_write_error:
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
},
});
}
}
return apiSuccess({
processed: entries.length,
inserted,
+17
View File
@@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -129,6 +130,22 @@ async function postMoodEntry(request: NextRequest) {
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
// v1.4.39 W-MOOD — refresh the persisted DAY rollup for the
// entry's bucket and enqueue the WEEK / MONTH / YEAR folds.
// Best-effort: a failure here must not surface as a 5xx to the
// user, the rollup is a cache tier, not a write-path invariant.
try {
await recomputeMoodBucketsForEntry(user.id, moodLoggedAt);
} catch (rollupErr) {
annotate({
meta: {
mood_rollup_write_failed: true,
mood_rollup_write_error:
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
},
});
}
return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201);
} catch (err) {
if (
@@ -0,0 +1,433 @@
/**
* v1.4.39 W-MOOD — integration coverage for the rollup-tier read swap
* in `/api/mood/analytics`.
*
* Pins the parity contract the audit at
* `.planning/round-v1438-perf-analysis.md` §2.3 calls for: the
* response shape stays byte-identical when the rollup tier is warm,
* and falls back to the legacy live walk once when a legacy account
* has mood entries but no rollup rows yet.
*
* The fast path consumes `prisma.moodEntryRollup.findMany`; the
* coverage fallback consumes `prisma.moodEntry.findMany` (legacy
* shape). Both branches go through the `cached(caches.moodAnalytics)`
* LRU so the second read in a test must observe the same response.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
findMany: vi.fn(),
findFirst: vi.fn().mockResolvedValue(null),
},
moodEntryRollup: {
findMany: vi.fn(),
findFirst: vi.fn().mockResolvedValue(null),
},
$queryRawUnsafe: vi.fn().mockResolvedValue([]),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the warm-up helper so test runs don't fire the real
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-
// forget on the route so its return value is irrelevant for parity.
vi.mock("@/lib/mood/rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/mood/rollups")>(
"@/lib/mood/rollups",
);
return {
...actual,
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
};
});
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_USER = {
id: "user-mood-1",
username: "marc",
role: "USER" as const,
timezone: "Europe/Berlin",
heightCm: 180,
dateOfBirth: new Date("1980-01-01T00:00:00Z"),
sourcePriorityJson: null,
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: SESSION_USER as never,
};
const callGet = GET as unknown as (...args: never[]) => Promise<Response>;
interface MoodAnalyticsBody {
data: {
entries: Array<{ date: string; score: number; samples: number }>;
summary: {
count: number;
latest: number | null;
min: number | null;
max: number | null;
mean: number | null;
};
};
}
beforeEach(() => {
vi.resetAllMocks();
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/mood/analytics", () => {
it("returns an empty envelope for a brand-new user with zero rollups and zero raw entries", async () => {
// Both branches return an empty result: rollup tier empty, raw
// mood-entry table empty. The legacy `entries` array is also
// empty so the response is identical to the pre-swap shape.
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as MoodAnalyticsBody;
expect(body.data.entries).toEqual([]);
expect(body.data.summary.count).toBe(0);
expect(body.data.summary.latest).toBeNull();
expect(body.data.summary.min).toBeNull();
expect(body.data.summary.max).toBeNull();
expect(body.data.summary.mean).toBeNull();
});
it("consumes the rollup tier when DAY rows exist and skips the raw findMany", async () => {
// One DAY-rollup row per calendar day, three days running.
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-08T00:00:00.000Z"),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-09T00:00:00.000Z"),
count: 1,
mean: 5,
minScore: 5,
maxScore: 5,
sd: null,
computedAt: new Date(),
},
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 2,
mean: 4.5,
minScore: 4,
maxScore: 5,
sd: 0.5,
computedAt: new Date(),
},
] as never);
const res = await callGet();
expect(res.status).toBe(200);
// The raw `moodEntry.findMany` must not fire on the fast path.
expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
const body = (await res.json()) as MoodAnalyticsBody;
expect(body.data.entries).toEqual([
{ date: "2026-05-08", score: 4, samples: 1 },
{ date: "2026-05-09", score: 5, samples: 1 },
{ date: "2026-05-10", score: 4.5, samples: 2 },
]);
expect(body.data.summary.count).toBe(3);
expect(body.data.summary.latest).toBe(4.5);
expect(body.data.summary.min).toBe(4);
expect(body.data.summary.max).toBe(5);
});
it("falls back to the live findMany once when the rollup tier is empty but mood entries exist", async () => {
// Coverage-fallback: legacy account with raw entries but no
// rollup coverage yet. The route runs the legacy walk once;
// the warm-up helper is supposed to fire asynchronously
// (stubbed above).
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{
date: "2026-05-08",
score: 4,
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
},
{
date: "2026-05-09",
score: 5,
moodLoggedAt: new Date("2026-05-09T12:00:00.000Z"),
},
] as never);
const res = await callGet();
expect(res.status).toBe(200);
// Live fallback fired exactly once.
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
const body = (await res.json()) as MoodAnalyticsBody;
expect(body.data.entries).toEqual([
{ date: "2026-05-08", score: 4, samples: 1 },
{ date: "2026-05-09", score: 5, samples: 1 },
]);
expect(body.data.summary.count).toBe(2);
expect(body.data.summary.latest).toBe(5);
});
it("pins the DST fall-back Berlin midnight slip — UTC bucket vs local day diverge by one calendar day", async () => {
// QA Specialist-H1 (v1.4.39): 2025-10-25T23:30:00Z is 00:30 local
// in Europe/Berlin on 2025-10-26 (one hour AFTER the fall-back
// transition). The mood rollup writer anchors `bucketStart` on
// UTC midnight (mirroring the measurement-rollup convention), so
// a write logged at this instant materialises a rollup row keyed
// on `2025-10-25T00:00:00Z` — the UTC-anchored day — even though
// the user's local wall clock reads 2025-10-26. The route's
// `utcDayLabel` emits the same day-key the rollup row carries.
//
// This pins the documented behaviour. The legacy live-fallback
// path uses `MoodEntry.date` (TZ-anchored) and therefore emits
// `2025-10-26` for the same instant — a slip we document in the
// route's header comment as the cost of the cache-tier semantics.
// The v1.5 per-user-tz bucketing (P7 in the v1.4.38 perf audit)
// closes the gap by anchoring the rollup table on the same day-
// key the legacy path uses. Until then this is the contract.
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2025-10-25T00:00:00.000Z"),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
] as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as MoodAnalyticsBody;
// The rollup tier pins the entry on the UTC-anchored day-key,
// NOT the user's local 2025-10-26 — same convention as the
// measurement-rollup tier.
expect(body.data.entries).toEqual([
{ date: "2025-10-25", score: 4, samples: 1 },
]);
});
it("serves the cached envelope on a warm read without touching the DB", async () => {
// First read populates the cache.
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
] as never);
const first = await callGet();
expect(first.status).toBe(200);
expect(prisma.moodEntryRollup.findMany).toHaveBeenCalledTimes(1);
// Second read hits the LRU and never touches Prisma again.
const second = await callGet();
expect(second.status).toBe(200);
expect(prisma.moodEntryRollup.findMany).toHaveBeenCalledTimes(1);
const firstBody = (await first.json()) as MoodAnalyticsBody;
const secondBody = (await second.json()) as MoodAnalyticsBody;
expect(secondBody).toEqual(firstBody);
});
it("pre-aggregates the live fallback through daily averages so multi-entry days match the rollup-path summarize semantics", async () => {
// QA UX-H1 (v1.4.39): two entries on the same day (score 3 + 5)
// must produce identical `summary.count / latest / min / max /
// mean` on the rollup tier (one DataPoint with mean=4) AND on the
// live-fallback path (which used to pass per-entry points).
const moodLoggedAt = new Date("2026-05-10T12:00:00.000Z");
// Live-fallback first: two entries on the same day.
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ date: "2026-05-10", score: 3, moodLoggedAt },
{ date: "2026-05-10", score: 5, moodLoggedAt },
] as never);
const liveRes = await callGet();
const liveBody = (await liveRes.json()) as MoodAnalyticsBody;
__resetAllCachesForTests();
// Rollup tier: one DAY row with count=2 / mean=4.
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 2,
mean: 4,
minScore: 3,
maxScore: 5,
sd: 1,
computedAt: new Date(),
},
] as never);
const rollupRes = await callGet();
const rollupBody = (await rollupRes.json()) as MoodAnalyticsBody;
// Both branches emit a single daily-average entry with score=4.
expect(liveBody.data.entries).toEqual([
{ date: "2026-05-10", score: 4, samples: 2 },
]);
expect(rollupBody.data.entries).toEqual([
{ date: "2026-05-10", score: 4, samples: 2 },
]);
// Summary stats now match across both branches — pre-v1.4.39 the
// live path passed per-entry points so `count` was 2 (per entry)
// and `mean` was the unweighted average over entries. After the
// pre-aggregate the live path emits one DataPoint per day (mean=4)
// exactly as the rollup tier does.
expect(liveBody.data.summary.count).toBe(rollupBody.data.summary.count);
expect(liveBody.data.summary.latest).toBe(rollupBody.data.summary.latest);
expect(liveBody.data.summary.min).toBe(rollupBody.data.summary.min);
expect(liveBody.data.summary.max).toBe(rollupBody.data.summary.max);
expect(liveBody.data.summary.mean).toBe(rollupBody.data.summary.mean);
});
it("emits byte-identical entries + summary shape between rollup tier and live fallback for the same canonical data", async () => {
// Same one-entry-per-day series, surfaced once via the rollup
// tier and once via the legacy live walk. Response shape is
// expected to be byte-compatible — the rollup carries the
// pre-aggregated daily mean which is precisely what
// aggregateDailyAverages would produce from a single entry
// per day.
const canonicalEntries = [
{
date: "2026-05-08",
score: 3,
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
},
{
date: "2026-05-09",
score: 4,
moodLoggedAt: new Date("2026-05-09T12:00:00.000Z"),
},
{
date: "2026-05-10",
score: 5,
moodLoggedAt: new Date("2026-05-10T12:00:00.000Z"),
},
];
// Live-fallback request first (rollup empty + raw populated).
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue(
canonicalEntries as never,
);
const liveRes = await callGet();
const liveBody = (await liveRes.json()) as MoodAnalyticsBody;
// Bust the cache before the rollup-tier read so the second call
// re-evaluates the read path with the rollup populated.
__resetAllCachesForTests();
vi.mocked(prisma.moodEntryRollup.findMany).mockResolvedValue([
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-08T00:00:00.000Z"),
count: 1,
mean: 3,
minScore: 3,
maxScore: 3,
sd: null,
computedAt: new Date(),
},
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-09T00:00:00.000Z"),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
{
userId: "user-mood-1",
granularity: "DAY",
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 5,
minScore: 5,
maxScore: 5,
sd: null,
computedAt: new Date(),
},
] as never);
const rollupRes = await callGet();
const rollupBody = (await rollupRes.json()) as MoodAnalyticsBody;
// `entries` shape — date / score / samples — must be byte-identical.
expect(rollupBody.data.entries).toEqual(liveBody.data.entries);
// Summary core stats must match (count / min / max / mean /
// latest). Slope windows depend on Date.now() so we don't compare
// those — they are deterministic for the same input and the
// input is identical here.
expect(rollupBody.data.summary.count).toBe(liveBody.data.summary.count);
expect(rollupBody.data.summary.latest).toBe(liveBody.data.summary.latest);
expect(rollupBody.data.summary.min).toBe(liveBody.data.summary.min);
expect(rollupBody.data.summary.max).toBe(liveBody.data.summary.max);
expect(rollupBody.data.summary.mean).toBe(liveBody.data.summary.mean);
});
});
+104 -2
View File
@@ -4,9 +4,24 @@ import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/mood/rollups";
export const dynamic = "force-dynamic";
/**
* v1.4.39 W-MOOD — five-year window for the rollup-tier read.
*
* Mirrors the legacy "unbounded findMany" semantics in practice: the
* legacy code passed every mood the user had ever logged into Node.
* The rollup tier stores one row per day, so a five-year cap is
* cheap (at most ~1 800 rows) and still covers every user the
* product has historic data for.
*/
const ROLLUP_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000;
/** Aggregate multiple mood entries per day into daily averages. */
function aggregateDailyAverages(
records: Array<{ date: string; score: number }>,
@@ -33,9 +48,81 @@ interface MoodAnalyticsResult {
entryCount: number;
}
/**
* Format the rollup bucket's UTC `bucket_start` as a YYYY-MM-DD
* label. The legacy live path emitted the row's TZ-anchored `date`
* column; the rollup tier anchors on UTC midnight (same convention
* as the measurement rollup tier). For tenants within ±3 h of UTC
* (Berlin year-round) the two labels agree on every entry whose
* timestamp doesn't straddle the UTC boundary — i.e. every realistic
* mood log, which a human submits during waking hours.
*
* Trade-off (QA Specialist-H1, v1.4.39): on DST fall-back nights the
* UTC anchor and the user's local wall-clock day-key can diverge by
* one calendar day. Example: `2025-10-25T23:30:00Z` is 00:30 local
* in Europe/Berlin on `2025-10-26` (one hour after the fall-back
* transition); the rollup row is keyed on `2025-10-25` (UTC) while
* the legacy live-fallback path would emit `2025-10-26`. This is
* pinned by the route-parity DST test. v1.5 per-user-tz bucketing
* (audit P7) anchors the rollup on the same day-key the legacy path
* uses, closing the gap.
*/
function utcDayLabel(d: Date): string {
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
async function buildMoodAnalyticsResponse(
userId: string,
): Promise<MoodAnalyticsResult> {
// v1.4.39 W-MOOD — fire-and-forget warm-up so the next cold mount
// for this user lands on the rollup tier even when the boot-time
// backfill hasn't reached them yet.
void ensureUserMoodRollupsFresh(userId);
const since = new Date(Date.now() - ROLLUP_WINDOW_MS);
const rollups = await readMoodDayRollups(userId, since);
if (rollups.length > 0) {
// Fast path — one DAY-rollup row per calendar day. The legacy
// `aggregateDailyAverages` collapse is unnecessary because the
// rollup already carries the daily mean; we recreate its output
// shape directly so the response stays byte-compatible.
const entries = rollups
.map((r) => ({
date: utcDayLabel(r.bucketStart),
score: Math.round(r.mean * 100) / 100,
samples: r.count,
}))
.sort((a, b) => a.date.localeCompare(b.date));
// Summarize feeds the slope7/30/90 windows. One DataPoint per
// day (mean) is the right resolution — the legacy path passed
// per-entry points, but the slope-window outputs match because
// a power user's mood is typically 1/day. Multi-entry days
// contribute their daily mean exactly once, which is the same
// semantic the dashboard tile renders.
const dataPoints: DataPoint[] = rollups.map((r) => ({
date: r.bucketStart,
value: r.mean,
}));
const summary = summarize(dataPoints);
const entryCount = rollups.reduce((s, r) => s + r.count, 0);
annotate({ meta: { mood_analytics_path: "rollup" } });
return { entries, summary, entryCount };
}
// Coverage-fallback — the user has no rollup rows yet. Probe for
// raw mood entries; when the table is empty for this user we
// return the same empty envelope the legacy path emitted. When
// mood entries exist but rollups don't (legacy account before the
// boot-backfill has caught up), we run the legacy live walk ONCE
// so the request still gets a correct response — the warm-up
// fired above will mint the rollups for the next request.
const moodEntries = await prisma.moodEntry.findMany({
where: { userId },
orderBy: { moodLoggedAt: "asc" },
@@ -46,13 +133,28 @@ async function buildMoodAnalyticsResponse(
moodEntries.map((e) => ({ date: e.date, score: e.score })),
);
const dataPoints: DataPoint[] = moodEntries.map((e) => ({
date: e.moodLoggedAt,
// QA UX-H1 (v1.4.39): feed `summarize()` per-day means, not per-entry
// scores. The rollup fast-path emits one DataPoint per calendar day
// (the rollup's `mean`); the live fallback used to pass every raw
// entry, which silently shifted `summary.count / latest / min / max
// / mean / avg7 / avg30 / slope30` on power-user multi-entry days.
// Pre-aggregating through `aggregateDailyAverages` keeps the two
// branches byte-identical on multi-entry days too. Date anchor is
// local-noon for the day so the slope x-axis spans whole-day units —
// mirrors the dashboard tile's intuition (one number per day).
const dataPoints: DataPoint[] = entries.map((e) => ({
date: new Date(`${e.date}T12:00:00.000Z`),
value: e.score,
}));
const summary = summarize(dataPoints);
annotate({
meta: {
mood_analytics_path: moodEntries.length === 0 ? "rollup" : "live",
},
});
return { entries, summary, entryCount: moodEntries.length };
}
+3
View File
@@ -40,6 +40,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const moodEntries = await tx.moodEntry.deleteMany({
where: { userId },
});
// v1.4.39 W-MOOD — wipe the persisted rollup partition for this
// user so the next analytics read returns an empty envelope.
await tx.moodEntryRollup.deleteMany({ where: { userId } });
const apiTokens = await tx.apiToken.deleteMany({
where: { userId },
});
+8
View File
@@ -8,6 +8,7 @@ import { moodLogCredentialsSchema } from "@/lib/validations/moodlog";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { markDisconnected, markReconnected } from "@/lib/integrations/status";
import { invalidateUserMood } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -74,6 +75,13 @@ export const DELETE = apiHandler(async () => {
where: { userId: user.id },
});
// v1.4.39 W-MOOD — every mood entry for this user just vanished, so
// every persisted rollup row for them is stale. Drop the whole
// (userId, *) partition so the next analytics read returns an
// empty envelope instead of stale daily means.
await prisma.moodEntryRollup.deleteMany({ where: { userId: user.id } });
invalidateUserMood(user.id);
// Clear credentials
await prisma.user.update({
where: { id: user.id },
+40 -3
View File
@@ -13,6 +13,7 @@ import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { locales, type Locale } from "@/lib/i18n/config";
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
interface TelegramUpdate {
update_id: number;
@@ -115,6 +116,9 @@ async function findTelegramUser(chatId: string) {
id: true,
telegramBotToken: true,
locale: true,
// v1.4.39 W-MED — pulled into the select so the post-write
// compliance-rollup hook can anchor the user-day bucket.
timezone: true,
},
});
}
@@ -124,6 +128,7 @@ async function markMedicationTaken(
medicationId: string,
idempotencyKey: string,
locale: Locale,
userTz: string | null,
): Promise<{ ok: boolean; message: string; medicationName?: string }> {
const { t } = getServerTranslator(locale);
const medication = await prisma.medication.findFirst({
@@ -139,14 +144,16 @@ async function markMedicationTaken(
select: { id: true },
});
let scheduledFor: Date | null = null;
if (!existing) {
scheduledFor = new Date();
await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
userId,
medicationId: medication.id,
scheduledFor: new Date(),
takenAt: new Date(),
scheduledFor,
takenAt: scheduledFor,
skipped: false,
source: "REMINDER",
idempotencyKey,
@@ -159,6 +166,18 @@ async function markMedicationTaken(
]);
}
// v1.4.39 W-MED — refresh the compliance rollup for the
// affected day so the next read after the cache miss reflects the
// new dose. Only fires when the create-path landed a new row.
if (scheduledFor) {
await recomputeMedicationComplianceForEvent({
userId,
medicationId: medication.id,
scheduledFor,
tz: userTz,
});
}
return {
ok: true,
message: existing
@@ -205,6 +224,7 @@ async function handleCallback(update: TelegramUpdate) {
medicationId,
idempotencyKey,
locale,
user.timezone,
);
await answerTelegramCallbackQuery(botToken, callback.id, result.message);
if (messageId) {
@@ -289,16 +309,18 @@ async function handleCallback(update: TelegramUpdate) {
select: { id: true },
});
let skippedScheduledFor: Date | null = null;
if (!existing) {
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
skippedScheduledFor = new Date();
await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: medication.id,
scheduledFor: new Date(),
scheduledFor: skippedScheduledFor,
takenAt: null,
skipped: true,
source: "REMINDER",
@@ -312,6 +334,18 @@ async function handleCallback(update: TelegramUpdate) {
]);
}
// v1.4.39 W-MED — refresh the compliance rollup for the skipped
// event's user-day so the next read sees the (scheduled, skipped)
// counts incremented.
if (skippedScheduledFor) {
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: medication.id,
scheduledFor: skippedScheduledFor,
tz: user.timezone,
});
}
await answerTelegramCallbackQuery(
botToken,
callback.id,
@@ -372,6 +406,7 @@ async function handleCallback(update: TelegramUpdate) {
medicationId,
idempotencyKey,
locale,
user.timezone,
);
await answerTelegramCallbackQuery(botToken, callback.id, result.message);
if (messageId) {
@@ -477,6 +512,7 @@ async function handleTextMessage(update: TelegramUpdate) {
meds[0].id,
idempotencyKey,
locale,
user.timezone,
);
const resp = await sendTelegramMessage(
botToken,
@@ -611,6 +647,7 @@ async function handleTextMessage(update: TelegramUpdate) {
medicationId,
idempotencyKey,
locale,
user.timezone,
);
const resp = await sendTelegramMessage(
botToken,
@@ -27,7 +27,10 @@ vi.mock("@/lib/logging/context", () => ({
}));
import { prisma } from "@/lib/db";
import { computeSummariesSlice } from "../summaries-slice";
import {
computeLongWindowSummary,
computeSummariesSlice,
} from "../summaries-slice";
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
const MEASUREMENT_FIND_FIRST =
@@ -292,3 +295,137 @@ describe("computeSummariesSlice", () => {
});
});
});
/**
* v1.4.39 W-WMY — long-window summary helper. Pins:
* - the helper routes a 365-day window through MONTH buckets,
* - the linearly-composable aggregate (count / min / max / mean /
* sum) lands at the same numbers `aggregateWmyBuckets` produces
* directly, so the rollup math survives round-tripping through
* the summaries-slice entry point,
* - a coverage miss surfaces as a `null` return instead of a
* partial-shape leak.
*/
describe("computeLongWindowSummary", () => {
it("routes a 365-day window through MONTH buckets and composes the aggregate", async () => {
// The first findMany call is the per-tier probe inside
// `readBestGranularityRollups`. With windowDays=365 the router
// walks YEAR (skipped, floor=731) → MONTH (floor=181, matched).
// The MONTH call returns two buckets; the helper composes the
// window aggregate from them.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-04-01T00:00:00.000Z"),
count: 10,
mean: 82,
sd: 1,
slope: -0.01,
r2: 0.3,
sumValue: null,
minValue: 79,
maxValue: 84,
},
{
bucketStart: new Date("2026-05-01T00:00:00.000Z"),
count: 20,
mean: 80,
sd: 1.5,
slope: 0.02,
r2: 0.4,
sumValue: null,
minValue: 77,
maxValue: 83,
},
]);
const result = await computeLongWindowSummary("user-1", "WEIGHT", 365);
expect(result).not.toBeNull();
expect(result?.granularity).toBe("MONTH");
expect(result?.bucketCount).toBe(2);
expect(result?.count).toBe(30);
expect(result?.min).toBe(77);
expect(result?.max).toBe(84);
// Σ(count × mean) / Σcount = (10×82 + 20×80) / 30 = 80.6666…
expect(result?.mean).toBeCloseTo((10 * 82 + 20 * 80) / 30, 5);
expect(result?.sum).toBeNull(); // no sumValue on these buckets
// The router asked MONTH first (365 d clears the 181-day MONTH
// floor and falls short of the 731-day YEAR floor).
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1);
expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH");
});
it("routes a 1095-day window through YEAR with a fall-through to MONTH on miss", async () => {
// YEAR coverage miss → router falls back to MONTH.
ROLLUP_FIND_MANY.mockResolvedValueOnce([]) // YEAR miss
.mockResolvedValueOnce([
{
bucketStart: new Date("2024-06-01T00:00:00.000Z"),
count: 100,
mean: 81,
sd: 1.2,
slope: 0.005,
r2: 0.15,
sumValue: null,
minValue: 76,
maxValue: 86,
},
]);
const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095);
expect(result?.granularity).toBe("MONTH");
expect(result?.bucketCount).toBe(1);
expect(result?.count).toBe(100);
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2);
});
it("composes the cumulative sum when every bucket carries sumValue", async () => {
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-04-01T00:00:00.000Z"),
count: 28,
mean: 8_200,
sd: 1_000,
slope: 0,
r2: 0,
sumValue: 229_600,
minValue: 5_000,
maxValue: 12_000,
},
{
bucketStart: new Date("2026-05-01T00:00:00.000Z"),
count: 30,
mean: 9_100,
sd: 1_100,
slope: 0,
r2: 0,
sumValue: 273_000,
minValue: 6_000,
maxValue: 12_500,
},
]);
const result = await computeLongWindowSummary(
"user-1",
"ACTIVITY_STEPS",
365,
);
expect(result?.sum).toBe(229_600 + 273_000);
});
it("returns null when every granularity misses", async () => {
ROLLUP_FIND_MANY.mockResolvedValue([]);
const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095);
expect(result).toBeNull();
});
it("returns null on a zero / negative / NaN window without hitting the DB", async () => {
expect(await computeLongWindowSummary("user-1", "WEIGHT", 0)).toBeNull();
expect(await computeLongWindowSummary("user-1", "WEIGHT", -10)).toBeNull();
expect(await computeLongWindowSummary("user-1", "WEIGHT", NaN)).toBeNull();
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
});
+100
View File
@@ -53,6 +53,8 @@
* 100`. Slope `direction` is derived from `slope`'s sign with the
* same 0.01-units-per-day "stable" threshold the JS helper uses.
*/
import type { MeasurementType, RollupGranularity } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
@@ -62,6 +64,11 @@ import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
import {
aggregateWmyBuckets,
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/measurements/rollup-read-wmy";
/**
* Heavy aggregate row — used on the cold-mount fallback path where the
@@ -560,3 +567,96 @@ async function computeFromLiveAggregate(
return { summaries, bmi: null, lastSeenByType };
}
/**
* v1.4.39 W-WMY — long-window summary for a single `(userId, type)`
* pair, served from the WEEK / MONTH / YEAR rollup tier when the
* window justifies it.
*
* Why this helper exists
* ----------------------
* The slim `computeSummariesSlice` caps its windowed columns at 90 d
* (`slope7 / slope30 / slope90`). The v1.5 multi-year trend feature
* + the Coach drawer's "history" tile need linearly composable stats
* (count / min / max / mean / sum) over much larger windows — 1 y,
* 2 y, 3 y. Hitting the live `measurements` table for a 3-year span
* walks every row the user has ever logged for that type, which on
* Marc's tenant is tens of thousands of rows per type.
*
* The WMY rollup tier already carries the per-bucket stats we need:
* the writer mints WEEK / MONTH / YEAR buckets via pg-boss on every
* measurement write and the boot backfill fills the long tail. This
* helper routes the requested window into the largest granularity
* that still resolves it (via `readBestGranularityRollups`) and
* composes the trailing-window aggregate in JS — typically 3-15
* bucket rows on a multi-year window, vs the thousands of raw rows
* the live aggregator would scan.
*
* Compositional caveats
* ---------------------
* `count / min / max / mean / sum` are mathematically exact across
* any granularity (linearly composable). `sd / slope / r2` are NOT
* exact across coarser buckets — this helper deliberately omits them
* so callers cannot accidentally consume stale slope data. The v1.5
* multi-year trend card derives its slope from the per-bucket `mean`
* series instead.
*
* Coverage-miss policy
* --------------------
* `readBestGranularityRollups` falls back from YEAR → MONTH → WEEK
* → DAY on per-tier coverage miss. Returns `null` only when the
* user has zero buckets in the entire window — the caller treats
* that as "no data" rather than "stale rollup". The route is
* intentionally NOT wired to live SQL fallback here; the all-time
* aggregate path inside `computeSummariesSlice` already covers the
* "rollup tier empty" cold-mount case, and v1.5's multi-year card
* accepts a `null` "no data yet" state.
*/
export async function computeLongWindowSummary(
userId: string,
type: MeasurementType,
windowDays: number,
): Promise<{
granularity: RollupGranularity;
bucketStart: Date | null;
bucketEnd: Date | null;
bucketCount: number;
count: number;
min: number | null;
max: number | null;
mean: number | null;
sum: number | null;
} | null> {
const resolved = await readBestGranularityRollups(userId, type, windowDays);
if (!resolved) return null;
const rows: RollupBucketRow[] = resolved.rows;
const aggregate = aggregateWmyBuckets(rows);
// Annotate which granularity served the window so the operator can
// verify the routing in production wide-events without redeploying
// instrumentation. Mirrors the `slim_summaries.path` annotate
// shape inside `computeFromRollups` / `computeFromLiveAggregate`.
annotate({
meta: {
analytics: {
long_window_summary: {
type,
window_days: windowDays,
granularity: resolved.granularity,
bucket_count: rows.length,
row_count: aggregate.count,
},
},
},
});
return {
granularity: resolved.granularity,
bucketStart: rows[0]?.bucketStart ?? null,
bucketEnd: rows[rows.length - 1]?.bucketStart ?? null,
bucketCount: rows.length,
count: aggregate.count,
min: aggregate.min,
max: aggregate.max,
mean: aggregate.mean,
sum: aggregate.sum,
};
}
+180
View File
@@ -67,6 +67,24 @@ import {
type RollupFullBackfillPayload,
type RollupRecomputePayload,
} from "@/lib/measurements/rollups";
import {
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY,
MOOD_ROLLUP_RECOMPUTE_QUEUE,
MOOD_ROLLUP_RECOMPUTE_CONCURRENCY,
enqueueBootTimeMoodRollupBackfill,
recomputeUserMoodRollups,
type MoodRollupFullBackfillPayload,
type MoodRollupRecomputePayload,
} from "@/lib/mood/rollups";
import {
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY,
recomputeMedicationComplianceForEvent,
recomputeUserMedicationCompliance,
enqueueBootTimeMedicationComplianceBackfill,
type MedicationComplianceBackfillPayload,
} from "@/lib/medications/compliance-rollups";
import {
drainPerSampleCumulative,
DRAIN_CUMULATIVE_CUTOFF_HOURS,
@@ -567,6 +585,17 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
"missed_dose",
`${med.name}:${schedule.windowStart}-${schedule.windowEnd}`,
);
// v1.4.39 W-MED — the worker just minted a fresh
// `scheduledFor` row with `takenAt: null`; refresh the
// compliance rollup so the per-day `scheduled` count
// increments before any read sees the user's tile.
await recomputeMedicationComplianceForEvent({
userId: med.user.id,
medicationId: med.id,
scheduledFor,
tz: med.user.timezone,
});
}
}
@@ -1655,6 +1684,21 @@ export async function startReminderWorker() {
APPLE_HEALTH_IMPORT_QUEUE,
ROLLUP_RECOMPUTE_QUEUE,
ROLLUP_FULL_BACKFILL_QUEUE,
// v1.4.39 W-MOOD — per-bucket WEEK/MONTH/YEAR fold queue for the
// mood rollup tier. The DAY pass runs inline in the write hook;
// these enqueue paths cover the cross-granularity buckets the
// worker materialises off the request path.
MOOD_ROLLUP_RECOMPUTE_QUEUE,
// v1.4.39 W-MOOD — boot-time fold queue for the mood rollup tier.
// Mirrors the measurement-rollup full-backfill semantics: the
// discovery query enqueues one full-fold per user with mood
// entries but no rollup rows; the user drops off the list once
// the fold completes.
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
// v1.4.39 W-MED — boot-time fold for the medication-compliance
// rollup tier. Discovery enqueues one job per user with intake
// events but no rollup coverage; idempotent across reboots.
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
// v1.4.37 W7c — explicit createQueue is required before the
// nightly schedule below registers (pg-boss v12 contract). Without
// this entry the drain schedule silently no-ops and the
@@ -1916,6 +1960,89 @@ export async function startReminderWorker() {
},
);
// v1.4.39 W-MOOD — mood-rollup per-bucket worker. Folds the
// WEEK / MONTH / YEAR buckets that the mood-entry write hooks
// enqueue; the DAY bucket runs synchronously in the hook itself.
// No current read path consumes these buckets — they exist so a
// future cross-granularity reader can ship without a backfill
// step. Concurrency-2 mirrors the measurement-rollup worker.
await boss.work<MoodRollupRecomputePayload>(
MOOD_ROLLUP_RECOMPUTE_QUEUE,
{ localConcurrency: MOOD_ROLLUP_RECOMPUTE_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const payload = job.data;
await recomputeUserMoodRollups(payload.userId, {
granularities: [payload.granularity],
from: new Date(payload.from),
to: new Date(payload.to),
});
}
},
);
// v1.4.39 W-MOOD — mood-rollup boot-time fold worker. The boot
// discovery helper below sends one job per user with mood entries
// but zero rollup rows; this handler folds the full 5-year window
// across every granularity. Concurrency-1 so the populator never
// crowds the request pool.
await boss.work<MoodRollupFullBackfillPayload>(
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
{ localConcurrency: MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
try {
const { rowsUpserted, durationMs } =
await recomputeUserMoodRollups(userId);
workerLog(
"info",
`[mood-rollup-full-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`,
);
} catch (err) {
recordError();
workerLog(
"error",
`[mood-rollup-full-backfill] user=${userId} failed`,
err,
);
throw err;
}
}
},
);
// v1.4.39 W-MED — medication-compliance boot-backfill worker. The
// discovery helper below sends one job per user with intake events
// but zero rollup coverage; this handler folds the trailing 90-day
// window per account. Concurrency-1 so the populator never crowds
// the request pool.
await boss.work<MedicationComplianceBackfillPayload>(
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
{ localConcurrency: MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
try {
const { rowsUpserted, durationMs } =
await recomputeUserMedicationCompliance(userId);
workerLog(
"info",
`[medication-compliance-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`,
);
} catch (err) {
recordError();
workerLog(
"error",
`[medication-compliance-backfill] user=${userId} failed`,
err,
);
throw err;
}
}
},
);
// v1.4.37 W7c — nightly drain worker. Walks every user × cumulative
// type and folds per-sample APPLE_HEALTH rows older than the cutoff
// into one `stats:…` row per calendar day. Idempotent — a second run
@@ -1980,6 +2107,59 @@ export async function startReminderWorker() {
);
}
// v1.4.39 W-MOOD — fire-and-forget boot discovery for the mood
// rollup tier. Mirrors the v1.4.35.1 measurement-rollup pattern:
// one job per user with mood entries but no rollup coverage.
// Idempotent across reboots and singleton-keyed inside pg-boss so
// a fast restart while a backfill is queued doesn't double up.
try {
const { enqueued, skipped, error } =
await enqueueBootTimeMoodRollupBackfill();
if (error) {
workerLog(
"error",
`[mood-rollup-full-backfill] boot discovery failed: ${error}`,
);
} else {
workerLog(
"info",
`[mood-rollup-full-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[mood-rollup-full-backfill] boot discovery threw an unexpected error",
err,
);
}
// v1.4.39 W-MED — fire-and-forget boot discovery for the medication
// compliance rollup tier. Mirrors the v1.4.35.1 pattern: one job per
// user with intake events but no rollup coverage. Idempotent across
// reboots and singleton-keyed inside pg-boss.
try {
const { enqueued, skipped, error } =
await enqueueBootTimeMedicationComplianceBackfill();
if (error) {
workerLog(
"error",
`[medication-compliance-backfill] boot discovery failed: ${error}`,
);
} else {
workerLog(
"info",
`[medication-compliance-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[medication-compliance-backfill] boot discovery threw an unexpected error",
err,
);
}
return boss;
}
@@ -0,0 +1,195 @@
/**
* v1.4.39 W-SUM — unit tests for the cumulative-day-sum reader helpers.
*
* `prisma.measurementRollup.findMany` is mocked so the tests pin the
* exact `where` shape the consumer sends (cumulative type set,
* granularity, since-bound) and the legacy-NULL fallback semantics of
* `resolveBucketSum`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findMany: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurementRollup: {
findMany: mocks.findMany,
},
},
}));
import { prisma } from "@/lib/db";
import {
isCumulativeType,
readCumulativeDaySums,
readCumulativeDaySumsBatch,
resolveBucketSum,
} from "../rollup-read-cumulative";
beforeEach(() => {
mocks.findMany.mockReset();
});
describe("isCumulativeType", () => {
it("recognises the five cumulative HK types", () => {
expect(isCumulativeType("ACTIVITY_STEPS")).toBe(true);
expect(isCumulativeType("ACTIVE_ENERGY_BURNED")).toBe(true);
expect(isCumulativeType("FLIGHTS_CLIMBED")).toBe(true);
expect(isCumulativeType("WALKING_RUNNING_DISTANCE")).toBe(true);
expect(isCumulativeType("TIME_IN_DAYLIGHT")).toBe(true);
});
it("rejects spot metrics", () => {
expect(isCumulativeType("WEIGHT")).toBe(false);
expect(isCumulativeType("BLOOD_PRESSURE_SYS")).toBe(false);
expect(isCumulativeType("PULSE")).toBe(false);
});
});
describe("readCumulativeDaySums", () => {
it("queries the rollup table with the cumulative type + DAY granularity", async () => {
const since = new Date("2026-04-15T00:00:00.000Z");
const rows = [
{
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: 8120,
count: 4,
mean: 2030,
},
{
bucketStart: new Date("2026-04-16T00:00:00.000Z"),
sumValue: 12480,
count: 5,
mean: 2496,
},
];
mocks.findMany.mockResolvedValueOnce(rows);
const out = await readCumulativeDaySums("user-1", "ACTIVITY_STEPS", since);
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: 8120,
count: 4,
mean: 2030,
});
// Single round-trip — eliminates the legacy per-type chunked
// findMany loop in analytics A2.
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
const args = mocks.findMany.mock.calls[0][0];
expect(args.where).toMatchObject({
userId: "user-1",
type: "ACTIVITY_STEPS",
granularity: "DAY",
bucketStart: { gte: since },
});
expect(args.orderBy).toEqual({ bucketStart: "asc" });
});
it("propagates legacy NULL sum_value rows so the caller can fall back", async () => {
mocks.findMany.mockResolvedValueOnce([
{
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: null,
count: 3,
mean: 1500,
},
]);
const [row] = await readCumulativeDaySums(
"user-1",
"ACTIVITY_STEPS",
new Date("2026-04-15T00:00:00.000Z"),
);
expect(row.sumValue).toBeNull();
// Algebraic fallback parity: pre-v1.4.39 buckets still surface
// a usable daily total during the boot-backfill convergence
// window.
expect(resolveBucketSum(row)).toBe(4500);
});
});
describe("readCumulativeDaySumsBatch", () => {
it("groups by type and seeds empty arrays for missing types", async () => {
const since = new Date("2026-04-15T00:00:00.000Z");
mocks.findMany.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: 8120,
count: 4,
mean: 2030,
},
{
type: "ACTIVITY_STEPS",
bucketStart: new Date("2026-04-16T00:00:00.000Z"),
sumValue: 12480,
count: 5,
mean: 2496,
},
{
type: "FLIGHTS_CLIMBED",
bucketStart: new Date("2026-04-15T00:00:00.000Z"),
sumValue: 22,
count: 3,
mean: 7.33,
},
]);
const out = await readCumulativeDaySumsBatch(
"user-1",
["ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "TIME_IN_DAYLIGHT"],
since,
);
expect(out.get("ACTIVITY_STEPS")).toHaveLength(2);
expect(out.get("FLIGHTS_CLIMBED")).toHaveLength(1);
// Type with zero rollup rows still surfaces as an empty array so
// the caller can avoid a `.get(...) ?? []` fallback per branch.
expect(out.get("TIME_IN_DAYLIGHT")).toEqual([]);
// Single findMany over the IN-list — replaces the per-type loop
// that drove the A2 cold path on Marc's tenant.
expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1);
const args = mocks.findMany.mock.calls[0][0];
expect(args.where.type).toEqual({
in: ["ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "TIME_IN_DAYLIGHT"],
});
});
it("returns an empty map without querying when types is empty", async () => {
const out = await readCumulativeDaySumsBatch(
"user-1",
[],
new Date("2026-04-15T00:00:00.000Z"),
);
expect(out.size).toBe(0);
expect(prisma.measurementRollup.findMany).not.toHaveBeenCalled();
});
});
describe("resolveBucketSum", () => {
it("returns sumValue when populated", () => {
expect(
resolveBucketSum({
bucketStart: new Date(),
sumValue: 7500,
count: 3,
mean: 2500,
}),
).toBe(7500);
});
it("falls back to mean * count when sumValue is null (legacy row)", () => {
expect(
resolveBucketSum({
bucketStart: new Date(),
sumValue: null,
count: 3,
mean: 2500,
}),
).toBe(7500);
});
});
@@ -0,0 +1,292 @@
/**
* v1.4.39 W-WMY — unit tests for the WEEK / MONTH / YEAR rollup
* readers and the auto-router that picks the largest granularity
* that still resolves a requested window.
*
* `prisma.measurementRollup.findMany` is mocked at the module level
* so the test pins:
* - each per-granularity reader filters by the correct
* `granularity` literal,
* - empty rollups return `null` so the caller can branch on
* coverage miss,
* - the auto-router walks YEAR → MONTH → WEEK → DAY and stops at
* the first granularity whose floor the window clears AND which
* has coverage,
* - `aggregateWmyBuckets` composes `count / min / max / mean / sum`
* linearly across coarser buckets (the same compositional
* contract `rollup-read.ts:aggregateBuckets` carries for DAY).
*
* Integration coverage against a real Postgres lives outside this
* file — the writer's `STDDEV_POP / REGR_SLOPE` semantics are pinned
* in `tests/integration/measurement-rollups.test.ts` already.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findMany: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurementRollup: {
findMany: mocks.findMany,
},
},
}));
import {
aggregateWmyBuckets,
readBestGranularityRollups,
readMonthRollups,
readWeekRollups,
readYearRollups,
type RollupBucketRow,
} from "../rollup-read-wmy";
const { findMany } = mocks;
function bucket(
bucketStart: string,
partial: Partial<RollupBucketRow> = {},
): RollupBucketRow {
return {
bucketStart: new Date(bucketStart),
count: 10,
mean: 82,
sd: 1,
slope: -0.01,
r2: 0.3,
sumValue: null,
minValue: 80,
maxValue: 84,
...partial,
};
}
beforeEach(() => {
findMany.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("readWeekRollups", () => {
it("returns the WEEK buckets for (userId, type, since)", async () => {
const rows = [
bucket("2026-04-27T00:00:00.000Z"),
bucket("2026-05-04T00:00:00.000Z", { mean: 81 }),
];
findMany.mockResolvedValueOnce(rows);
const since = new Date("2026-04-20T00:00:00.000Z");
const result = await readWeekRollups("user-1", "WEIGHT", since);
expect(result).not.toBeNull();
expect(result).toHaveLength(2);
expect(result?.[0].mean).toBe(82);
expect(result?.[1].mean).toBe(81);
const args = findMany.mock.calls[0][0];
expect(args.where.granularity).toBe("WEEK");
expect(args.where.userId).toBe("user-1");
expect(args.where.type).toBe("WEIGHT");
expect(args.where.bucketStart.gte).toBe(since);
expect(args.orderBy).toEqual({ bucketStart: "asc" });
});
it("returns null when the WEEK window has no coverage", async () => {
findMany.mockResolvedValueOnce([]);
const result = await readWeekRollups(
"user-1",
"WEIGHT",
new Date("2026-04-20T00:00:00.000Z"),
);
expect(result).toBeNull();
});
});
describe("readMonthRollups", () => {
it("returns the MONTH buckets and pins the granularity filter", async () => {
findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]);
const result = await readMonthRollups(
"user-2",
"PULSE",
new Date("2026-01-01T00:00:00.000Z"),
);
expect(result).toHaveLength(1);
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
});
it("returns null on coverage miss", async () => {
findMany.mockResolvedValueOnce([]);
const result = await readMonthRollups(
"user-2",
"PULSE",
new Date("2026-01-01T00:00:00.000Z"),
);
expect(result).toBeNull();
});
});
describe("readYearRollups", () => {
it("returns the YEAR buckets and pins the granularity filter", async () => {
findMany.mockResolvedValueOnce([bucket("2025-01-01T00:00:00.000Z")]);
const result = await readYearRollups(
"user-3",
"WEIGHT",
new Date("2023-01-01T00:00:00.000Z"),
);
expect(result).toHaveLength(1);
expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR");
});
it("returns null on coverage miss", async () => {
findMany.mockResolvedValueOnce([]);
const result = await readYearRollups(
"user-3",
"WEIGHT",
new Date("2023-01-01T00:00:00.000Z"),
);
expect(result).toBeNull();
});
});
describe("readBestGranularityRollups", () => {
it("returns null on a non-positive window", async () => {
expect(await readBestGranularityRollups("user", "WEIGHT", 0)).toBeNull();
expect(await readBestGranularityRollups("user", "WEIGHT", -10)).toBeNull();
expect(await readBestGranularityRollups("user", "WEIGHT", NaN)).toBeNull();
expect(findMany).not.toHaveBeenCalled();
});
it("routes a 90-day window to DAY (every coarser floor is too low)", async () => {
findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]);
const result = await readBestGranularityRollups("user", "WEIGHT", 90);
expect(result).not.toBeNull();
expect(result?.granularity).toBe("DAY");
expect(findMany).toHaveBeenCalledTimes(1);
expect(findMany.mock.calls[0][0].where.granularity).toBe("DAY");
});
it("routes a 365-day window to MONTH (clears the 62-day MONTH floor)", async () => {
findMany.mockResolvedValueOnce([bucket("2026-01-01T00:00:00.000Z")]);
const result = await readBestGranularityRollups("user", "WEIGHT", 365);
expect(result).not.toBeNull();
expect(result?.granularity).toBe("MONTH");
expect(findMany).toHaveBeenCalledTimes(1);
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
});
it("routes a 1095-day window to YEAR (clears the 730-day YEAR floor)", async () => {
findMany.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]);
const result = await readBestGranularityRollups("user", "WEIGHT", 1095);
expect(result).not.toBeNull();
expect(result?.granularity).toBe("YEAR");
expect(findMany).toHaveBeenCalledTimes(1);
expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR");
});
it("falls back to MONTH when YEAR has no coverage", async () => {
// 1095-day window asks for YEAR first; coverage miss → MONTH.
findMany
.mockResolvedValueOnce([]) // YEAR miss
.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // MONTH hit
const result = await readBestGranularityRollups("user", "WEIGHT", 1095);
expect(result?.granularity).toBe("MONTH");
expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR");
expect(findMany.mock.calls[1][0].where.granularity).toBe("MONTH");
});
it("falls all the way through to DAY when every coarser tier misses", async () => {
findMany
.mockResolvedValueOnce([]) // YEAR miss
.mockResolvedValueOnce([]) // MONTH miss
.mockResolvedValueOnce([]) // WEEK miss
.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // DAY hit
const result = await readBestGranularityRollups("user", "WEIGHT", 1095);
expect(result?.granularity).toBe("DAY");
expect(findMany).toHaveBeenCalledTimes(4);
});
it("returns null when no granularity carries any coverage", async () => {
findMany.mockResolvedValue([]);
const result = await readBestGranularityRollups("user", "WEIGHT", 1095);
expect(result).toBeNull();
// YEAR, MONTH, WEEK, DAY all probed.
expect(findMany).toHaveBeenCalledTimes(4);
});
});
describe("aggregateWmyBuckets", () => {
it("returns the empty-window shape on no rows", () => {
expect(aggregateWmyBuckets([])).toEqual({
count: 0,
min: null,
max: null,
mean: null,
sum: null,
});
});
it("sums count + folds min/max + weights mean across MONTH buckets", () => {
// Two MONTH buckets — same compositional contract as DAY because
// `count / min / max / mean` are linearly composable across any
// bucket granularity.
// April: count=10, mean=82, min=79, max=84
// May: count=20, mean=80, min=77, max=83
// ⇒ totalCount=30, min=77, max=84,
// mean = (10×82 + 20×80) / 30 = 80.6666…
const rows: RollupBucketRow[] = [
bucket("2026-04-01T00:00:00.000Z", {
count: 10,
mean: 82,
minValue: 79,
maxValue: 84,
}),
bucket("2026-05-01T00:00:00.000Z", {
count: 20,
mean: 80,
minValue: 77,
maxValue: 83,
}),
];
const result = aggregateWmyBuckets(rows);
expect(result.count).toBe(30);
expect(result.min).toBe(77);
expect(result.max).toBe(84);
expect(result.mean).toBeCloseTo((10 * 82 + 20 * 80) / 30, 5);
});
it("sums cumulative sumValue when every bucket carries one", () => {
const rows: RollupBucketRow[] = [
bucket("2026-04-01T00:00:00.000Z", { sumValue: 12_500 }),
bucket("2026-05-01T00:00:00.000Z", { sumValue: 8_200 }),
];
const result = aggregateWmyBuckets(rows);
expect(result.sum).toBe(20_700);
});
it("returns null sum when no bucket carries sumValue (pre-W-SUM data)", () => {
const rows: RollupBucketRow[] = [
bucket("2026-04-01T00:00:00.000Z", { sumValue: null }),
bucket("2026-05-01T00:00:00.000Z", { sumValue: null }),
];
expect(aggregateWmyBuckets(rows).sum).toBeNull();
});
it("treats sumValue NaN/Infinity as missing", () => {
const rows: RollupBucketRow[] = [
bucket("2026-04-01T00:00:00.000Z", { sumValue: 100 }),
bucket("2026-05-01T00:00:00.000Z", {
// simulate a Postgres NaN slipping through serialisation.
sumValue: Number.NaN,
}),
];
// Only the finite sumValue contributes.
expect(aggregateWmyBuckets(rows).sum).toBe(100);
});
});
@@ -155,6 +155,10 @@ describe("recomputeBucketsForMeasurement", () => {
mean: 82.5,
min_value: 80.0,
max_value: 85.0,
// v1.4.39 W-SUM — writer always populates `sum_value` (AVG
// and SUM compose in the same $queryRaw aggregate). 3 * 82.5
// = 247.5 so the round-trips line up with `mean * count`.
sum_value: 247.5,
sd: 2.0,
slope: -0.1,
r2: 0.9,
@@ -184,6 +188,11 @@ describe("recomputeBucketsForMeasurement", () => {
).toBe("DAY");
expect(upsertArg.create.count).toBe(3);
expect(upsertArg.create.mean).toBe(82.5);
// v1.4.39 W-SUM — sumValue flows through on both create + update
// halves of the upsert. Cumulative read paths (steps, flights,
// distance, daylight, active-energy) consume this directly.
expect(upsertArg.create.sumValue).toBe(247.5);
expect(upsertArg.update.sumValue).toBe(247.5);
// WEEK / MONTH / YEAR — three enqueues against the worker queue.
expect(bossSend).toHaveBeenCalledTimes(3);
@@ -195,6 +204,73 @@ describe("recomputeBucketsForMeasurement", () => {
}
});
it("writes sum_value for cumulative ACTIVITY_STEPS buckets", async () => {
// 5 step samples in a day summing to 12480: emulates an iOS
// batch of HealthKit slices reaching the rollup aggregator.
queryRawUnsafe.mockResolvedValueOnce([
{
type: "ACTIVITY_STEPS",
bucket_start: new Date("2026-05-10T00:00:00.000Z"),
count: BigInt(5),
mean: 2496,
min_value: 100,
max_value: 9000,
// 100 + 2500 + 9000 + 500 + 380 = 12480
sum_value: 12480,
sd: 3200,
slope: null,
r2: null,
},
]);
getGlobalBossMock.mockReturnValue(null);
await recomputeBucketsForMeasurement(
"user-7",
"ACTIVITY_STEPS",
new Date("2026-05-10T14:30:00.000Z"),
);
expect(upsert).toHaveBeenCalledTimes(1);
const arg = upsert.mock.calls[0][0];
expect(arg.create.sumValue).toBe(12480);
// mean × count algebraic equivalence — the writer carries SUM
// directly because the aggregator computed it once. Asserting
// both gives parity coverage for the consumer-side fallback.
expect(arg.create.mean * arg.create.count).toBe(12480);
});
it("passes through null sum_value when the aggregator returns NULL", async () => {
// Defensive: an empty WHERE-clause window would NEVER return a
// row here (the route filters by day boundaries), but a future
// aggregator change that adds a HAVING clause should still
// surface NULL cleanly rather than coercing to 0.
queryRawUnsafe.mockResolvedValueOnce([
{
type: "WEIGHT",
bucket_start: new Date("2026-05-10T00:00:00.000Z"),
count: BigInt(1),
mean: 82.5,
min_value: 82.5,
max_value: 82.5,
sum_value: null,
sd: null,
slope: null,
r2: null,
},
]);
getGlobalBossMock.mockReturnValue(null);
await recomputeBucketsForMeasurement(
"user-1",
"WEIGHT",
new Date("2026-05-10T14:30:00.000Z"),
);
const arg = upsert.mock.calls[0][0];
expect(arg.create.sumValue).toBeNull();
expect(arg.update.sumValue).toBeNull();
});
it("deletes the DAY row when the post-mutation aggregate is empty", async () => {
// Post-delete recompute: the day now has zero rows.
queryRawUnsafe.mockResolvedValueOnce([]);
@@ -466,4 +542,28 @@ describe("enqueueBootTimeRollupBackfill", () => {
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
expect(bossSend).not.toHaveBeenCalled();
});
// v1.4.39 W-SUM — the discovery query now unions in users whose
// existing DAY rollup rows carry `sum_value IS NULL`. Re-folding
// converges those rows because `persistRollupRows` always writes the
// new column on upsert; the union keeps the per-type missing-coverage
// gap (v1.4.38.5) and the legacy-NULL backfill on the same indexed
// pass.
it("includes the sum_value IS NULL branch in the discovery query", async () => {
getGlobalBossMock.mockReturnValue({ send: bossSend });
queryRaw.mockResolvedValueOnce([{ id: "user-with-legacy-rollups" }]);
bossSend.mockResolvedValueOnce("job-id");
const result = await enqueueBootTimeRollupBackfill();
expect(result).toEqual({ enqueued: 1, skipped: 0, error: null });
// The discovery SQL surfaces both per-type missing coverage AND
// legacy NULL sum_value rows under one UNION. The text-anchor is
// the only durable assertion at the unit level — the integration
// suite covers the planner shape on real Postgres.
const sqlParts = queryRaw.mock.calls[0][0] as TemplateStringsArray;
const sqlText = Array.isArray(sqlParts) ? sqlParts.join("?") : "";
expect(sqlText).toContain("sum_value");
expect(sqlText).toContain("UNION");
});
});
@@ -0,0 +1,167 @@
/**
* v1.4.39 W-SUM — read helpers for cumulative-metric daily sums.
*
* The v1.4.36 read path consumes the rollup table for spot-metric means
* via `rollup-read.ts`. Cumulative HK types (steps, flights, distance,
* daylight, active-energy) need the per-day SUM rather than the MEAN —
* every row is a partial-day slice from HealthKit, so MEAN understates
* the daily total by the per-bucket sample count.
*
* The v1.4.39 schema scaffolding (migration 0072) added `sum_value` to
* `measurement_rollups`. The writer in `rollups.ts` populates it on
* every fold (including for non-cumulative types — the cost is one
* extra `SUM` in the existing aggregator). The legacy-NULL backfill
* runs through `enqueueBootTimeRollupBackfill` so existing rows
* converge on the next worker boot.
*
* Callers fall back to `mean * count` when `sumValue` is null. The
* shortcut is mathematically exact for a single source per day:
*
* sum = mean × count = (Σvalue / count) × count = Σvalue
*
* Multiple sources for the same `(user, type, day)` mix into the same
* bucket today — the source-priority resolution that
* `pickCanonicalSourceRows` runs in JS happens AFTER the rollup
* aggregate is built. The W8c per-source rollup deferred to v1.5 fixes
* that; until then, the cumulative read paths trust the bucket sum as
* the canonical daily total and the iOS drain (which collapses per
* source via `dailyStatsExternalId`) keeps Marc's tenant on the
* single-source-per-day path.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { CUMULATIVE_HK_TYPES } from "./apple-health-mapping";
/**
* Cumulative measurement types the rollup `sum_value` column serves.
* Re-exported from the canonical Apple Health mapping so the
* consumer-side gate stays in one place.
*
* Audit anchor: `.planning/round-v1438-perf-analysis.md` §3
* "Cumulative daily sums" + §5 P3.
*/
export type CumulativeType =
| "ACTIVITY_STEPS"
| "ACTIVE_ENERGY_BURNED"
| "FLIGHTS_CLIMBED"
| "WALKING_RUNNING_DISTANCE"
| "TIME_IN_DAYLIGHT";
/**
* `true` when `type` is one of the cumulative HK types whose daily
* total the rollup `sum_value` column carries.
*/
export function isCumulativeType(type: MeasurementType): type is CumulativeType {
return CUMULATIVE_HK_TYPES.has(type);
}
/** One DAY-bucket row read from `measurement_rollups`. */
export interface CumulativeDaySumRow {
bucketStart: Date;
/**
* Per-bucket SUM. `null` only when the bucket pre-dates the v1.4.39
* writer change AND the boot-time backfill has not yet converged on
* this user. Callers fall back to `mean * count` for the null case.
*/
sumValue: number | null;
count: number;
mean: number;
}
/**
* Read every DAY bucket for `(userId, type)` since `since`. Returns
* rows ascending by `bucketStart`. Pre-v1.4.39 rows surface here with
* `sumValue = null`; the caller decides whether to fall back to
* `mean * count` or skip them.
*
* Bounded to the cumulative-type set so a misuse (passing a spot
* metric) is a type error rather than a silent SUM on the wrong
* column. The query is a plain `findMany` against the
* `(userId, type, granularity, bucketStart)` composite primary key —
* single round-trip, fully indexed.
*/
export async function readCumulativeDaySums(
userId: string,
type: CumulativeType,
since: Date,
): Promise<CumulativeDaySumRow[]> {
const rows = await prisma.measurementRollup.findMany({
where: {
userId,
type: type as MeasurementType,
granularity: "DAY",
bucketStart: { gte: since },
},
orderBy: { bucketStart: "asc" },
select: {
bucketStart: true,
sumValue: true,
count: true,
mean: true,
},
});
return rows.map((r) => ({
bucketStart: r.bucketStart,
sumValue: r.sumValue,
count: r.count,
mean: r.mean,
}));
}
/**
* Batch read DAY buckets for several cumulative types in one round
* trip. Eliminates the per-type chunked-findMany loop in
* `/api/analytics` A2 for the five highest-row-count metrics on
* Marc's tenant.
*
* Returns a Map keyed on `MeasurementType` so the caller can route
* each type's rows back into the per-metric branch without scanning
* the flat list.
*/
export async function readCumulativeDaySumsBatch(
userId: string,
types: readonly CumulativeType[],
since: Date,
): Promise<Map<CumulativeType, CumulativeDaySumRow[]>> {
if (types.length === 0) return new Map();
const rows = await prisma.measurementRollup.findMany({
where: {
userId,
type: { in: types as unknown as MeasurementType[] },
granularity: "DAY",
bucketStart: { gte: since },
},
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
select: {
type: true,
bucketStart: true,
sumValue: true,
count: true,
mean: true,
},
});
const out = new Map<CumulativeType, CumulativeDaySumRow[]>();
for (const t of types) out.set(t, []);
for (const row of rows) {
const bucket = out.get(row.type as CumulativeType);
if (!bucket) continue;
bucket.push({
bucketStart: row.bucketStart,
sumValue: row.sumValue,
count: row.count,
mean: row.mean,
});
}
return out;
}
/**
* Resolve a per-bucket cumulative total. Reads `sumValue` when the
* v1.4.39 writer has populated it; falls back to `mean * count` for
* legacy NULL rows so the chart never paints a hole during the boot-
* backfill convergence window.
*/
export function resolveBucketSum(row: CumulativeDaySumRow): number {
if (row.sumValue !== null) return row.sumValue;
return row.mean * row.count;
}
+290
View File
@@ -0,0 +1,290 @@
/**
* v1.4.39 W-WMY — WEEK / MONTH / YEAR rollup readers.
*
* Background — the read-side gap
* ------------------------------
* Every measurement write fans out via `recomputeBucketsForMeasurement`
* into a synchronous DAY upsert plus pg-boss-queued WEEK / MONTH / YEAR
* recomputes. The boot-time backfill (`enqueueBootTimeRollupBackfill`)
* mints the full WEEK / MONTH / YEAR history once per uncovered user.
*
* As of v1.4.38 the only reader that consults `measurement_rollups`
* (`rollup-read.ts`, plus the per-fast-path branches in
* `summaries-slice`, `comprehensive-aggregator`, etc.) caps at DAY and
* a trailing 90-day window. The WEEK / MONTH / YEAR buckets sit in
* Postgres as pure write amplification — populated every write,
* surfaced nowhere on read. The v1.4.38 perf audit
* (`.planning/round-v1438-perf-analysis.md` §2 + §5 P6) calls this out
* as the largest unused investment in the rollup tier.
*
* What this module adds
* ---------------------
* Helpers that read the trailing N-bucket window for a single
* `(userId, type)` pair at a chosen granularity, plus an auto-router
* that picks the largest granularity that still resolves the requested
* window. The shape returned is identical to `readRollupBuckets` so
* callers can interleave WEEK / MONTH / YEAR rows with DAY rows
* downstream without branching on the source granularity.
*
* Compositional contract
* ----------------------
* `count / min / max / mean / sumValue` are linearly composable across
* any granularity — aggregating these stats over WEEK buckets returns
* the same numbers as aggregating the underlying DAY buckets or the
* raw measurements. `sd / slope / r2` are NOT linearly composable; the
* routed callers either fall back to live SQL for those or accept the
* per-bucket stat as-is. The auto-router is a granularity selector
* only — it does not attempt to reconstruct slope across coarser
* buckets.
*
* Coverage-miss semantics
* -----------------------
* Each reader returns `null` when the requested granularity yields
* zero rows for `(userId, type, since)`. The caller decides whether
* that is a real "no data" case (the user has not logged this type in
* the window) or a coverage miss the boot-backfill / worker has not
* caught up on. The default policy is to fall back to a finer
* granularity (MONTH → WEEK → DAY) before giving up — see
* `readBestGranularityRollups`.
*
* Scope vs. `rollup-read.ts`
* --------------------------
* `rollup-read.ts` carries the existing DAY-only aggregator. This
* module is deliberately separate so the WEEK / MONTH / YEAR wiring
* is reviewable in isolation and so the v1.4.39 W-SUM agent's
* concurrent edits to the writer side don't collide with the reader
* additions.
*/
import type {
MeasurementType,
RollupGranularity,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
/**
* Normalised bucket row returned by every WMY reader. Mirrors the
* shape `readRollupBuckets` returns plus the `sumValue` column the
* v1.4.39 W-SUM agent added to `MeasurementRollup`. `sumValue` is
* `null` for rows that pre-date the column's introduction; the boot
* backfill fills it in alongside the other stats.
*/
export interface RollupBucketRow {
bucketStart: Date;
count: number;
mean: number;
sd: number | null;
slope: number | null;
r2: number | null;
sumValue: number | null;
minValue: number;
maxValue: number;
}
/**
* Per-granularity "this is the smallest window the granularity can
* meaningfully resolve" floor. Used by `readBestGranularityRollups`
* to route a requested window into the coarsest tier that still
* carries enough buckets for the caller to do something useful.
*
* - DAY → any window — 90 daily buckets for a 90-day window
* is already trivially cheap and the trend resolution
* is canonical
* - WEEK → > 90 days (~13 weekly buckets for a quarter)
* - MONTH → > 180 days (~6 monthly buckets, enough trend signal
* for a half-year view; smaller windows benefit more
* from DAY-bucket granularity than coarse-grained
* averaging)
* - YEAR → > 730 days (≥ 2 yearly buckets — anything below 2 y
* collapses to one bucket and carries no slope signal)
*
* The pinned routing the v1.5 multi-year trend card relies on:
* 90 d → DAY (90 buckets)
* 365 d → MONTH (12 buckets)
* 1095 d → YEAR (3 buckets)
*
* The floors are conservative on purpose — coarser tiers only
* activate when the row-count savings actually justify trading the
* finer trend resolution.
*/
const GRANULARITY_FLOORS: Array<{
granularity: RollupGranularity;
minWindowDays: number;
}> = [
{ granularity: "YEAR", minWindowDays: 731 },
{ granularity: "MONTH", minWindowDays: 181 },
{ granularity: "WEEK", minWindowDays: 91 },
{ granularity: "DAY", minWindowDays: 0 },
];
/**
* Read the trailing WEEK buckets for `(userId, type)` since `since`.
* Returns `null` when no buckets exist in the window — the caller
* decides whether to fall back to a finer granularity or to live SQL.
*/
export async function readWeekRollups(
userId: string,
type: MeasurementType,
since: Date,
): Promise<RollupBucketRow[] | null> {
return readGranularity(userId, type, "WEEK", since);
}
/**
* Read the trailing MONTH buckets for `(userId, type)` since `since`.
* Returns `null` on coverage miss.
*/
export async function readMonthRollups(
userId: string,
type: MeasurementType,
since: Date,
): Promise<RollupBucketRow[] | null> {
return readGranularity(userId, type, "MONTH", since);
}
/**
* Read the trailing YEAR buckets for `(userId, type)` since `since`.
* Returns `null` on coverage miss.
*/
export async function readYearRollups(
userId: string,
type: MeasurementType,
since: Date,
): Promise<RollupBucketRow[] | null> {
return readGranularity(userId, type, "YEAR", since);
}
/**
* Pick the largest granularity that can still resolve the requested
* `windowDays` window — WEEK for >14 d, MONTH for >62 d, YEAR for
* >730 d. Falls back to a finer granularity on coverage miss so a
* user with WEEK / MONTH coverage but no YEAR buckets (e.g. a tenant
* who joined less than two years ago) still gets a usable trend
* series.
*
* Returns the granularity the helper resolved against plus the row
* shape `readWeekRollups` / `readMonthRollups` / `readYearRollups`
* return. `null` when no granularity yields any buckets in the
* window — the caller is expected to short-circuit to "no data" or
* to live SQL.
*/
export async function readBestGranularityRollups(
userId: string,
type: MeasurementType,
windowDays: number,
): Promise<{
granularity: RollupGranularity;
rows: RollupBucketRow[];
} | null> {
if (!Number.isFinite(windowDays) || windowDays <= 0) return null;
const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
// Walk the floors from coarsest to finest and return the first
// granularity whose floor the window clears AND which has coverage
// for `(userId, type, since)`. The `if rows == null` fall-through
// is what makes the helper resilient to partial coverage.
for (const floor of GRANULARITY_FLOORS) {
if (windowDays < floor.minWindowDays) continue;
const rows = await readGranularity(userId, type, floor.granularity, since);
if (rows && rows.length > 0) {
return { granularity: floor.granularity, rows };
}
}
return null;
}
/**
* Linearly compose `count / min / max / mean / sum` across an array
* of bucket rows. Mirrors `rollup-read.ts:aggregateBuckets` but
* carries `sumValue` so cumulative-metric callers (steps, energy,
* distance) can read the window total without re-deriving from
* `mean * count`. SD / slope / r2 are intentionally omitted — those
* stats do not compose across coarser buckets and the consumers that
* need them stay on live SQL.
*/
export function aggregateWmyBuckets(rows: RollupBucketRow[]): {
count: number;
min: number | null;
max: number | null;
mean: number | null;
sum: number | null;
} {
if (rows.length === 0) {
return { count: 0, min: null, max: null, mean: null, sum: null };
}
let totalCount = 0;
let sumWeighted = 0;
let sumCumulative = 0;
let sawSum = false;
let min = Infinity;
let max = -Infinity;
for (const row of rows) {
totalCount += row.count;
sumWeighted += row.count * row.mean;
if (row.sumValue !== null && Number.isFinite(row.sumValue)) {
sumCumulative += row.sumValue;
sawSum = true;
}
if (row.minValue < min) min = row.minValue;
if (row.maxValue > max) max = row.maxValue;
}
if (totalCount === 0) {
return { count: 0, min: null, max: null, mean: null, sum: null };
}
return {
count: totalCount,
min: Number.isFinite(min) ? min : null,
max: Number.isFinite(max) ? max : null,
mean: sumWeighted / totalCount,
sum: sawSum ? sumCumulative : null,
};
}
/**
* Internal — shared `findMany` projection. Returns `null` when the
* window has zero rows so the caller can branch on coverage miss
* without a separate count round-trip.
*/
async function readGranularity(
userId: string,
type: MeasurementType,
granularity: RollupGranularity,
since: Date,
): Promise<RollupBucketRow[] | null> {
// Bounded `findMany`: `(userId, type, granularity, bucketStart)`
// is the composite primary key so the planner picks the index path
// every time. `bucketStart >= since` is the same shape
// `readRollupBuckets` uses; we don't carry an upper bound because
// the helpers are always reading "trailing window to now".
const rows = await prisma.measurementRollup.findMany({
where: {
userId,
type,
granularity,
bucketStart: { gte: since },
},
orderBy: { bucketStart: "asc" },
select: {
bucketStart: true,
count: true,
mean: true,
sd: true,
slope: true,
r2: true,
sumValue: true,
minValue: true,
maxValue: true,
},
});
if (rows.length === 0) return null;
return rows.map((r) => ({
bucketStart: r.bucketStart,
count: r.count,
mean: r.mean,
sd: r.sd,
slope: r.slope,
r2: r.r2,
sumValue: r.sumValue,
minValue: r.minValue,
maxValue: r.maxValue,
}));
}
+35 -10
View File
@@ -107,6 +107,7 @@ interface RollupRow {
mean: number | null;
min_value: number | null;
max_value: number | null;
sum_value: number | null;
sd: number | null;
slope: number | null;
r2: number | null;
@@ -387,6 +388,7 @@ async function runRollupAggregate(input: {
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
SUM(m."value")::double precision AS sum_value,
STDDEV_POP(m."value")::double precision AS sd,
REGR_SLOPE(
m."value",
@@ -419,6 +421,7 @@ async function runRollupAggregate(input: {
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
SUM(m."value")::double precision AS sum_value,
STDDEV_POP(m."value")::double precision AS sd,
REGR_SLOPE(
m."value",
@@ -483,6 +486,12 @@ async function persistRollupRows(
mean: row.mean ?? 0,
minValue: row.min_value ?? 0,
maxValue: row.max_value ?? 0,
// v1.4.39 W-SUM — populate for every type. Cumulative read
// paths (steps, flights, distance, daylight, active-energy)
// consume this column directly; spot metrics carry it for
// free because the underlying SUM aggregator runs in the
// same query as AVG / MIN / MAX.
sumValue: row.sum_value ?? null,
sd: row.sd,
slope: row.slope,
r2: row.r2,
@@ -493,6 +502,7 @@ async function persistRollupRows(
mean: row.mean ?? 0,
minValue: row.min_value ?? 0,
maxValue: row.max_value ?? 0,
sumValue: row.sum_value ?? null,
sd: row.sd,
slope: row.slope,
r2: row.r2,
@@ -741,17 +751,32 @@ export async function enqueueBootTimeRollupBackfill(): Promise<{
// the LEFT JOIN) marks the unmatched partitions. A user surfaces
// here when ANY type is missing — including the brand-new-type
// case that v1.4.35.1's zero-rollup-only shape silently stranded.
//
// v1.4.39 W-SUM — the discovery also surfaces users whose existing
// DAY rollup rows carry `sum_value IS NULL`. Those rows pre-date
// the v1.4.39 writer change; re-folding the user converges them
// because `persistRollupRows` always writes the new column on
// upsert. The union runs in one query so the existing per-type
// coverage gap and the legacy-NULL backfill share a single planner
// pass.
const users = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT DISTINCT mt."user_id" AS id
FROM (
SELECT DISTINCT m."user_id", m."type"
FROM measurements m
) mt
LEFT JOIN measurement_rollups r
ON r."user_id" = mt."user_id"
AND r."type" = mt."type"
AND r."granularity" = 'DAY'
WHERE r."bucket_start" IS NULL
SELECT DISTINCT id FROM (
SELECT DISTINCT mt."user_id" AS id
FROM (
SELECT DISTINCT m."user_id", m."type"
FROM measurements m
) mt
LEFT JOIN measurement_rollups r
ON r."user_id" = mt."user_id"
AND r."type" = mt."type"
AND r."granularity" = 'DAY'
WHERE r."bucket_start" IS NULL
UNION
SELECT DISTINCT r2."user_id" AS id
FROM measurement_rollups r2
WHERE r2."granularity" = 'DAY'
AND r2."sum_value" IS NULL
) discovery
`;
if (users.length === 0) {
@@ -0,0 +1,475 @@
/**
* v1.4.39 W-MED — unit tests for the medication-compliance rollup tier.
*
* `prisma` is mocked at the module boundary so the test can pin the
* intake-event fan-out + the rollup upsert / read shape without
* standing up a real Postgres. Integration coverage (real Postgres +
* the FK cascade behaviour) lives next to the route test suite when
* the W-MED phase grows them; this file proves the helpers'
* contract.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
intakeFindMany: vi.fn(),
rollupUpsert: vi.fn(),
rollupDeleteMany: vi.fn(),
rollupFindMany: vi.fn(),
rollupFindFirst: vi.fn(),
queryRaw: vi.fn(),
executeRaw: vi.fn(),
bossSend: vi.fn(),
getGlobalBossMock: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
medicationIntakeEvent: {
findMany: mocks.intakeFindMany,
},
medicationComplianceRollup: {
upsert: mocks.rollupUpsert,
deleteMany: mocks.rollupDeleteMany,
findMany: mocks.rollupFindMany,
findFirst: mocks.rollupFindFirst,
},
$queryRaw: mocks.queryRaw,
$executeRaw: mocks.executeRaw,
},
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => mocks.getGlobalBossMock(),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
recomputeMedicationComplianceForEvent,
readMedicationCompliance,
hasMedicationComplianceCoverage,
recomputeUserMedicationCompliance,
enqueueBootTimeMedicationComplianceBackfill,
enqueueUserMedicationComplianceBackfill,
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
} from "../compliance-rollups";
const {
intakeFindMany,
rollupUpsert,
rollupDeleteMany,
rollupFindMany,
rollupFindFirst,
queryRaw,
executeRaw,
bossSend,
getGlobalBossMock,
} = mocks;
beforeEach(() => {
vi.resetAllMocks();
intakeFindMany.mockResolvedValue([]);
rollupUpsert.mockResolvedValue({});
rollupDeleteMany.mockResolvedValue({ count: 0 });
rollupFindMany.mockResolvedValue([]);
rollupFindFirst.mockResolvedValue(null);
queryRaw.mockResolvedValue([]);
executeRaw.mockResolvedValue(0);
});
afterEach(() => {
vi.useRealTimers();
});
describe("dayKeyForScheduledFor", () => {
it("anchors the dayKey on the user's tz wall clock", () => {
// 2026-05-17T23:30:00Z — Berlin local is 2026-05-18 01:30 (CEST).
const ts = new Date("2026-05-17T23:30:00.000Z");
expect(dayKeyForScheduledFor(ts, "Europe/Berlin")).toBe("2026-05-18");
// Los Angeles is UTC-7 (PDT) — still 2026-05-17 16:30 local.
expect(dayKeyForScheduledFor(ts, "America/Los_Angeles")).toBe("2026-05-17");
});
it("falls back to the server default when tz is null", () => {
const ts = new Date("2026-05-17T22:00:00.000Z");
expect(dayKeyForScheduledFor(ts, null)).toBe(
dayKeyForScheduledFor(ts, "Europe/Berlin"),
);
});
});
describe("recomputeMedicationComplianceForDay", () => {
/**
* Helper: pull the bind-variable values out of a Prisma tagged-template
* call so the test can inspect the user-id / day / window bounds the
* SQL was rendered with. Prisma's `$executeRaw` tag passes the
* template strings as the first arg and the substituted values as the
* remaining positional args.
*/
function bindValues(call: unknown[]): unknown[] {
return call.slice(1);
}
it("runs an atomic INSERT … ON CONFLICT upsert with the (user, medication, day) tuple", async () => {
// QA F-H-02 (v1.4.39): the recompute is now a single SQL statement
// that re-aggregates inside the upsert; the JS pre-fold from the
// previous tier is gone so the test inspects the bind values
// instead of the intake fold.
await recomputeMedicationComplianceForDay(
"user-1",
"med-1",
"2026-05-18",
"Europe/Berlin",
);
// Two statements fire: the INSERT … ON CONFLICT upsert and the
// companion DELETE for the "all events removed" branch.
expect(executeRaw).toHaveBeenCalledTimes(2);
const insertBinds = bindValues(executeRaw.mock.calls[0]);
expect(insertBinds).toContain("user-1");
expect(insertBinds).toContain("med-1");
expect(insertBinds).toContain("2026-05-18");
const deleteBinds = bindValues(executeRaw.mock.calls[1]);
expect(deleteBinds).toContain("user-1");
expect(deleteBinds).toContain("med-1");
expect(deleteBinds).toContain("2026-05-18");
});
it("emits a paired DELETE so a fully-cleared day removes its row", async () => {
// The DELETE fires unconditionally and gates on NOT EXISTS — when
// the day still has events the predicate matches zero rows. The
// test pins that the DELETE statement was issued so a future
// refactor that drops the second statement breaks here.
await recomputeMedicationComplianceForDay(
"user-1",
"med-1",
"2026-05-18",
"Europe/Berlin",
);
expect(executeRaw).toHaveBeenCalledTimes(2);
});
it("is idempotent across repeated invocations", async () => {
await recomputeMedicationComplianceForDay(
"user-1",
"med-1",
"2026-05-18",
"Europe/Berlin",
);
await recomputeMedicationComplianceForDay(
"user-1",
"med-1",
"2026-05-18",
"Europe/Berlin",
);
expect(executeRaw).toHaveBeenCalledTimes(4);
expect(bindValues(executeRaw.mock.calls[0])).toEqual(
bindValues(executeRaw.mock.calls[2]),
);
expect(bindValues(executeRaw.mock.calls[1])).toEqual(
bindValues(executeRaw.mock.calls[3]),
);
});
it("binds a tz-anchored UTC window into the upsert", async () => {
await recomputeMedicationComplianceForDay(
"user-1",
"med-1",
"2026-05-18",
"Europe/Berlin",
);
const insertBinds = bindValues(executeRaw.mock.calls[0]);
const insertDates = insertBinds.filter((b): b is Date => b instanceof Date);
// Berlin 2026-05-18 00:00 → UTC 2026-05-17 22:00 (CEST is UTC+2).
expect(insertDates[0].toISOString()).toBe("2026-05-17T22:00:00.000Z");
// Window closes 24h later — Berlin 2026-05-19 00:00 → UTC 2026-05-18 22:00.
expect(insertDates[1].toISOString()).toBe("2026-05-18T22:00:00.000Z");
});
it("buckets the same UTC instant on different days for Berlin vs LA", async () => {
await recomputeMedicationComplianceForDay(
"user-1",
"med-1",
"2026-05-18",
"Europe/Berlin",
);
const berlinInsert = bindValues(executeRaw.mock.calls[0]).filter(
(b): b is Date => b instanceof Date,
);
executeRaw.mockClear();
await recomputeMedicationComplianceForDay(
"user-2",
"med-2",
"2026-05-17",
"America/Los_Angeles",
);
const laInsert = bindValues(executeRaw.mock.calls[0]).filter(
(b): b is Date => b instanceof Date,
);
// Berlin 2026-05-18 starts at UTC 2026-05-17 22:00.
expect(berlinInsert[0].toISOString()).toBe("2026-05-17T22:00:00.000Z");
// LA 2026-05-17 starts at UTC 2026-05-17 07:00 (PDT is UTC-7).
expect(laInsert[0].toISOString()).toBe("2026-05-17T07:00:00.000Z");
});
it("commits the strictest aggregate under concurrent recompute calls", async () => {
// QA F-H-02 race pin: two concurrent recomputes for the same
// (user, medication, day) must both go through the atomic SQL
// path. The earlier `findMany`-then-`upsert` pattern could
// interleave A-SELECT → B-SELECT → B-UPSERT (correct) → A-UPSERT
// (stale N-1); the atomic upsert serialises on the row lock so
// each statement re-aggregates a snapshot taken after the prior
// commit. The mock can't reproduce real Postgres concurrency, but
// the test pins that BOTH callers issue the atomic statement (not
// the legacy read-aggregate-then-upsert) so a regression to the
// racy pattern would fail this assertion.
await Promise.all([
recomputeMedicationComplianceForDay(
"user-1",
"med-1",
"2026-05-18",
"Europe/Berlin",
),
recomputeMedicationComplianceForDay(
"user-1",
"med-1",
"2026-05-18",
"Europe/Berlin",
),
]);
// 2 callers × 2 statements (INSERT + DELETE) each.
expect(executeRaw).toHaveBeenCalledTimes(4);
// No legacy intake-findMany was issued — the atomic path replaces it.
expect(intakeFindMany).not.toHaveBeenCalled();
// No legacy rollupUpsert / rollupDeleteMany either.
expect(rollupUpsert).not.toHaveBeenCalled();
expect(rollupDeleteMany).not.toHaveBeenCalled();
});
});
describe("recomputeMedicationComplianceForEvent", () => {
it("derives the dayKey from scheduledFor + tz then dispatches", async () => {
// 2026-05-17T23:30:00Z → Berlin day 2026-05-18.
await recomputeMedicationComplianceForEvent({
userId: "user-1",
medicationId: "med-1",
scheduledFor: new Date("2026-05-17T23:30:00.000Z"),
tz: "Europe/Berlin",
});
// The atomic upsert binds the dayKey as one of the positional
// arguments; assert "2026-05-18" reached the SQL layer.
const insertBinds = executeRaw.mock.calls[0].slice(1);
expect(insertBinds).toContain("2026-05-18");
});
it("swallows recompute errors so the parent write never blocks", async () => {
executeRaw.mockRejectedValueOnce(new Error("DB melted"));
await expect(
recomputeMedicationComplianceForEvent({
userId: "user-1",
medicationId: "med-1",
scheduledFor: new Date("2026-05-17T12:00:00.000Z"),
tz: "Europe/Berlin",
}),
).resolves.toBeUndefined();
});
});
describe("readMedicationCompliance", () => {
it("returns a trailing-window zero-filled bucket array", async () => {
rollupFindMany.mockResolvedValue([
{ day: "2026-05-18", scheduled: 2, taken: 1, skipped: 1 },
]);
const now = new Date("2026-05-18T12:00:00.000Z");
const buckets = await readMedicationCompliance(
"user-1",
3,
"Europe/Berlin",
now,
);
expect(buckets).toHaveLength(3);
expect(buckets[buckets.length - 1]).toEqual({
date: "2026-05-18",
scheduled: 2,
taken: 1,
});
expect(buckets[0].scheduled).toBe(0);
expect(buckets[0].taken).toBe(0);
expect(buckets.map((b) => b.date)).toEqual([
"2026-05-16",
"2026-05-17",
"2026-05-18",
]);
});
it("folds per-medication rows into one per-day total", async () => {
rollupFindMany.mockResolvedValue([
{ day: "2026-05-18", scheduled: 1, taken: 1, skipped: 0 },
{ day: "2026-05-18", scheduled: 2, taken: 0, skipped: 1 },
]);
const now = new Date("2026-05-18T12:00:00.000Z");
const buckets = await readMedicationCompliance(
"user-1",
1,
"Europe/Berlin",
now,
);
expect(buckets[0].scheduled).toBe(3);
expect(buckets[0].taken).toBe(1);
});
});
describe("hasMedicationComplianceCoverage", () => {
it("returns true when rolled-day count meets the event-day count in window", async () => {
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(7), event_days: BigInt(7) },
]);
await expect(
hasMedicationComplianceCoverage(
"user-1",
7,
"Europe/Berlin",
new Date("2026-05-18T12:00:00.000Z"),
),
).resolves.toBe(true);
});
it("returns true when the user has zero events in window (trivially covered)", async () => {
// Zero events → the read path returns a trailing-window zero-fill
// from an empty rollup table; covered semantically.
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(0) },
]);
await expect(
hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"),
).resolves.toBe(true);
});
it("returns false on partial coverage (boot backfill mid-fold)", async () => {
// QA F-H-01 (v1.4.39): the legacy "any row exists" probe would
// flip true here and serve zero-filled buckets for days N..days-1
// that the backfill hasn't reached yet. With the tightened probe
// partial coverage forces the route into the legacy aggregator
// until the fold completes.
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(2), event_days: BigInt(7) },
]);
await expect(
hasMedicationComplianceCoverage(
"user-1",
7,
"Europe/Berlin",
new Date("2026-05-18T12:00:00.000Z"),
),
).resolves.toBe(false);
});
it("returns false on zero rollups when events exist (legacy account cold start)", async () => {
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(7) },
]);
await expect(
hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"),
).resolves.toBe(false);
});
});
describe("recomputeUserMedicationCompliance", () => {
it("upserts one rollup per (medication, day) pair returned by discovery", async () => {
queryRaw.mockResolvedValue([
{ medication_id: "med-1", day: "2026-05-17" },
{ medication_id: "med-1", day: "2026-05-18" },
{ medication_id: "med-2", day: "2026-05-18" },
]);
const result = await recomputeUserMedicationCompliance(
"user-1",
30,
"Europe/Berlin",
);
expect(result.rowsUpserted).toBe(3);
// Each (medication, day) pair issues two atomic SQL statements:
// the INSERT … ON CONFLICT upsert and the companion DELETE that
// gates on `NOT EXISTS`.
expect(executeRaw).toHaveBeenCalledTimes(6);
});
});
describe("enqueueBootTimeMedicationComplianceBackfill", () => {
it("is a no-op when no boss is attached", async () => {
getGlobalBossMock.mockReturnValue(null);
const result = await enqueueBootTimeMedicationComplianceBackfill();
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
expect(queryRaw).not.toHaveBeenCalled();
});
it("enqueues one job per uncovered user", async () => {
getGlobalBossMock.mockReturnValue({ send: bossSend });
queryRaw.mockResolvedValue([{ id: "user-1" }, { id: "user-2" }]);
bossSend.mockResolvedValueOnce("job-1");
bossSend.mockResolvedValueOnce(null); // coalesced
const result = await enqueueBootTimeMedicationComplianceBackfill();
expect(result.enqueued).toBe(1);
expect(result.skipped).toBe(1);
expect(result.error).toBeNull();
expect(bossSend).toHaveBeenCalledWith(
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
expect.objectContaining({ userId: "user-1" }),
expect.objectContaining({
singletonKey: "medication-compliance-boot-backfill|user-1",
}),
);
});
});
describe("enqueueUserMedicationComplianceBackfill", () => {
it("is a no-op when no boss is attached", async () => {
getGlobalBossMock.mockReturnValue(null);
const result = await enqueueUserMedicationComplianceBackfill("user-1");
expect(result).toEqual({ enqueued: false, error: null });
// Critically: no `$queryRaw` cluster-wide LEFT JOIN scan.
expect(queryRaw).not.toHaveBeenCalled();
});
it("enqueues exactly one job for the caller's user and never touches the cluster-wide LEFT JOIN", async () => {
// QA F-SEC-M-01 (v1.4.39): pre-fix the request-path coverage-miss
// path called `enqueueBootTimeMedicationComplianceBackfill` which
// issued a cluster-wide `LEFT JOIN medication_intake_events ×
// medication_compliance_rollups` on every hit. The user-scoped
// helper sends one targeted job and never runs the discovery scan.
getGlobalBossMock.mockReturnValue({ send: bossSend });
bossSend.mockResolvedValueOnce("job-1");
const result = await enqueueUserMedicationComplianceBackfill("user-1");
expect(result).toEqual({ enqueued: true, error: null });
expect(queryRaw).not.toHaveBeenCalled();
expect(bossSend).toHaveBeenCalledTimes(1);
expect(bossSend).toHaveBeenCalledWith(
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
expect.objectContaining({ userId: "user-1" }),
expect.objectContaining({
singletonKey: "medication-compliance-boot-backfill|user-1",
}),
);
});
it("reports a coalesced enqueue as `enqueued:false`", async () => {
getGlobalBossMock.mockReturnValue({ send: bossSend });
bossSend.mockResolvedValueOnce(null);
const result = await enqueueUserMedicationComplianceBackfill("user-2");
expect(result).toEqual({ enqueued: false, error: null });
});
});
+637
View File
@@ -0,0 +1,637 @@
/**
* v1.4.39 W-MED — persistent medication-compliance rollup tier.
*
* Replaces the unbounded `MedicationIntakeEvent.findMany` walk on
* `/api/medications/intake?scope=compliance` with a ledger keyed
* `(userId, medicationId, day)` where `day` is `YYYY-MM-DD` anchored
* to the user's IANA timezone.
*
* Write hooks fire from every `MedicationIntakeEvent` mutation site
* (create / update / delete / bulk import) plus the reminder-worker's
* mint path that creates `takenAt:null` rows on RED-phase reminders.
* Reads collapse the trailing N-day window into a single bounded
* `findMany` against `medication_compliance_rollups`; the route-level
* coverage probe falls back to live + fires a boot backfill when a
* user has intake events but zero rollup rows.
*
* Audit anchor: `.planning/round-v1438-perf-analysis.md` §2.5 + §5 P4.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
import {
DEFAULT_TIMEZONE,
isValidTimezone,
userDayKey,
} from "@/lib/tz/format";
/**
* pg-boss queue name for the boot-time medication compliance backfill.
* Mirrors the `rollup-full-backfill` v1.4.35.1 pattern: discovery is
* idempotent across reboots and the worker upserts every
* `(user, medication, day)` row for the trailing window.
*/
export const MEDICATION_COMPLIANCE_BACKFILL_QUEUE =
"medication-compliance-full-backfill";
/** Worker concurrency cap — backfill stays serial to spare the pool. */
export const MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY = 1;
/** Trailing window the boot backfill folds per uncovered user. */
export const MEDICATION_COMPLIANCE_BACKFILL_DAYS = 90;
/** Payload `boss.send` carries onto the backfill queue. */
export interface MedicationComplianceBackfillPayload {
userId: string;
/** Wall-clock kick-off for debugging only. */
enqueuedAt: string;
}
/** Reader-result row shape — matches `buildComplianceBuckets` output. */
export interface ComplianceBucket {
/** YYYY-MM-DD in the user's tz. */
date: string;
scheduled: number;
taken: number;
}
/**
* Prisma client surface the helpers accept. Either the regular
* `prisma` singleton or a `$transaction(tx)` argument — both expose
* the `medicationIntakeEvent.findMany` + `medicationComplianceRollup.*`
* methods we touch.
*/
type PrismaLike = typeof prisma | Prisma.TransactionClient;
/**
* Compute the wall-clock components of `date` interpreted in `tz`.
* Same shape as the cadence helper's `wallClockInTz`, lifted here so
* the compliance module doesn't depend on a sibling-folder helper.
*/
function wallClockInTz(
date: Date,
tz: string,
): { year: number; month: number; day: number } {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
};
}
/**
* UTC offset (in minutes) of `tz` at the given instant. Positive east
* of UTC. Honours DST via `Intl.DateTimeFormat`.
*/
function tzOffsetMinutes(date: Date, tz: string): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
let hour = Number(get("hour"));
if (hour === 24) hour = 0;
const asIfUtc = Date.UTC(
Number(get("year")),
Number(get("month")) - 1,
Number(get("day")),
hour,
Number(get("minute")),
Number(get("second")),
);
return Math.round((asIfUtc - date.getTime()) / 60000);
}
/**
* The UTC instant corresponding to local-midnight on `dayKey` in `tz`.
* Two-pass convergence handles DST transitions correctly.
*/
function startOfDayUtcInTz(dayKey: string, tz: string): Date {
const [yearStr, monthStr, dayStr] = dayKey.split("-");
const year = Number(yearStr);
const month = Number(monthStr);
const day = Number(dayStr);
let guess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
for (let i = 0; i < 2; i++) {
const offsetMin = tzOffsetMinutes(guess, tz);
guess = new Date(
Date.UTC(year, month - 1, day, 0, 0, 0, 0) - offsetMin * 60_000,
);
}
return guess;
}
/**
* Resolve a safe tz string — fall back to the server default when the
* supplied value is missing or not an IANA zone the host recognises.
*/
function safeTimezone(tz: string | null | undefined): string {
if (tz && isValidTimezone(tz)) return tz;
return DEFAULT_TIMEZONE;
}
/**
* Convert a `scheduledFor` UTC instant to the user's `YYYY-MM-DD`
* day-key. Re-exposed so the write-hook caller can derive the same
* `dayKey` the rollup row is anchored on.
*/
export function dayKeyForScheduledFor(
scheduledFor: Date,
tz: string | null | undefined,
): string {
return userDayKey(scheduledFor, safeTimezone(tz));
}
/**
* Recompute the rollup row for one `(userId, medicationId, dayKey)`
* tuple. Reads every `MedicationIntakeEvent` whose `scheduledFor` lands
* in `[startOfDayInTz, startOfNextDayInTz)` then upserts the
* `medication_compliance_rollups` row.
*
* The hook is idempotent: re-running for the same key produces the
* same row. When the day window holds zero events the row is deleted
* so the read path returns the trailing-window zero-default rather
* than a stale stub.
*
* `tx` accepts the `prisma.$transaction(tx)` client argument when the
* parent write is transactional — passing the transaction client keeps
* the rollup write inside the same transactional boundary.
*/
export async function recomputeMedicationComplianceForDay(
userId: string,
medicationId: string,
dayKey: string,
tz: string | null | undefined,
tx?: PrismaLike,
): Promise<void> {
const client = (tx ?? prisma) as PrismaLike;
const safeTz = safeTimezone(tz);
const start = startOfDayUtcInTz(dayKey, safeTz);
const end = new Date(start.getTime() + 86_400_000);
// Re-derive end via the day-after boundary to honour DST 23 / 25-hour
// days. `start + 86_400_000` is correct for non-transition days; on
// a fall-back day we add one calendar day in the local zone.
const nextDayKey = (() => {
const [y, m, d] = dayKey.split("-").map(Number);
const probe = new Date(Date.UTC(y, m - 1, d, 12, 0, 0));
const probeNext = new Date(probe.getTime() + 86_400_000);
const parts = wallClockInTz(probeNext, safeTz);
return `${String(parts.year).padStart(4, "0")}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
})();
const dstSafeEnd = startOfDayUtcInTz(nextDayKey, safeTz);
const windowEnd = dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : end;
// v1.4.39 hotfix (QA F-H-02): atomic upsert closes the race window
// between the prior `SELECT … aggregate then UPSERT` pattern. Two
// concurrent writes for the same (user, medication, day) used to
// interleave A-SELECT → B-SELECT → B-UPSERT (correct) → A-UPSERT
// (stale N-1). The INSERT … SELECT re-aggregates inside the upsert
// statement so each write commits with a snapshot taken after the
// prior commit released its row lock; the trailing DELETE handles
// the "all events for the day were removed" case in the same shot.
//
// Both statements key on (user_id, medication_id, day) so they
// serialise behind the row lock the rollup table's primary key
// provides. The DELETE runs second so it cannot clobber a row the
// INSERT just wrote — when the day still has events the
// `WHERE NOT EXISTS` predicate matches zero rows.
await client.$executeRaw`
WITH aggregate AS (
SELECT
COUNT(*)::int AS scheduled,
SUM(CASE WHEN "skipped" THEN 0 WHEN "taken_at" IS NOT NULL THEN 1 ELSE 0 END)::int AS taken,
SUM(CASE WHEN "skipped" THEN 1 ELSE 0 END)::int AS skipped
FROM "medication_intake_events"
WHERE "user_id" = ${userId}
AND "medication_id" = ${medicationId}
AND "scheduled_for" >= ${start}
AND "scheduled_for" < ${windowEnd}
)
INSERT INTO "medication_compliance_rollups"
("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at")
SELECT
${userId},
${medicationId},
${dayKey},
aggregate.scheduled,
aggregate.taken,
aggregate.skipped,
NOW()
FROM aggregate
WHERE aggregate.scheduled > 0
ON CONFLICT ("user_id", "medication_id", "day") DO UPDATE
SET "scheduled" = EXCLUDED."scheduled",
"taken" = EXCLUDED."taken",
"skipped" = EXCLUDED."skipped",
"computed_at" = EXCLUDED."computed_at"
`;
await client.$executeRaw`
DELETE FROM "medication_compliance_rollups"
WHERE "user_id" = ${userId}
AND "medication_id" = ${medicationId}
AND "day" = ${dayKey}
AND NOT EXISTS (
SELECT 1
FROM "medication_intake_events"
WHERE "user_id" = ${userId}
AND "medication_id" = ${medicationId}
AND "scheduled_for" >= ${start}
AND "scheduled_for" < ${windowEnd}
)
`;
}
/**
* Convenience hook for write-paths that have the `scheduledFor`
* instant in hand. Derives `dayKey` in the user's tz then dispatches
* to `recomputeMedicationComplianceForDay`. Best-effort: surfaces
* failures through `annotate` + `console.error` so a silent populator
* regression shows up in ops without blocking the parent write.
*/
export async function recomputeMedicationComplianceForEvent(input: {
userId: string;
medicationId: string;
scheduledFor: Date;
tz: string | null | undefined;
tx?: PrismaLike;
}): Promise<void> {
const dayKey = dayKeyForScheduledFor(input.scheduledFor, input.tz);
try {
await recomputeMedicationComplianceForDay(
input.userId,
input.medicationId,
dayKey,
input.tz,
input.tx,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
medication_compliance_rollup_failed: true,
medication_compliance_rollup_error: message,
},
});
console.error(
"[medication-compliance-rollups] recompute failed:",
message,
);
}
}
/**
* Read trailing `days` of compliance buckets for the user, oldest day
* first. Returns the same `{date, scheduled, taken}` shape the legacy
* `buildComplianceBuckets` helper produced so the route swap is
* byte-identical.
*
* Days without a rollup row land as zero-filled buckets. The caller's
* cache wrapper carries the user-tz in its key so a tz change forces
* a fresh read.
*/
export async function readMedicationCompliance(
userId: string,
days: number,
tz: string | null | undefined,
now: Date = new Date(),
): Promise<ComplianceBucket[]> {
const safeTz = safeTimezone(tz);
// Build the expected day-key window oldest → newest so the response
// is sorted and zero-filled regardless of which days hold rollups.
const todayKey = userDayKey(now, safeTz);
const expectedKeys: string[] = [];
for (let i = days - 1; i >= 0; i--) {
const probe = new Date(now.getTime() - i * 86_400_000);
expectedKeys.push(userDayKey(probe, safeTz));
}
// Guard against a DST day-of-the-year landing the loop on the same
// dayKey twice — dedup by Map insertion order.
const dedup = new Set<string>();
const orderedKeys: string[] = [];
for (const key of expectedKeys) {
if (!dedup.has(key)) {
dedup.add(key);
orderedKeys.push(key);
}
}
// Always include today even if the trailing window dropped it (e.g.
// days===1 on a DST boundary). Cheap defensive insert.
if (!dedup.has(todayKey)) {
orderedKeys.push(todayKey);
}
const oldestKey = orderedKeys[0];
const newestKey = orderedKeys[orderedKeys.length - 1];
const rows = await prisma.medicationComplianceRollup.findMany({
where: {
userId,
day: { gte: oldestKey, lte: newestKey },
},
select: { day: true, scheduled: true, taken: true, skipped: true },
});
// Fold per-medication rows into one per-day total. The legacy route
// shape was aggregated across all the user's medications; preserving
// that surface keeps the front-end byte-stable.
const totals = new Map<string, { scheduled: number; taken: number }>();
for (const key of orderedKeys) {
totals.set(key, { scheduled: 0, taken: 0 });
}
for (const row of rows) {
const bucket = totals.get(row.day);
if (!bucket) continue;
bucket.scheduled += row.scheduled;
bucket.taken += row.taken;
}
return orderedKeys.map((date) => {
const bucket = totals.get(date) ?? { scheduled: 0, taken: 0 };
return { date, scheduled: bucket.scheduled, taken: bucket.taken };
});
}
/**
* Returns true when the rollup tier covers every day inside the
* trailing `days` window that has at least one intake event. Coverage
* is full when the count of DISTINCT rolled days equals the count of
* DISTINCT days that hold a `medication_intake_events` row in window —
* partial coverage (boot backfill mid-fold on a multi-medication
* account) returns false so the route falls through to the legacy
* aggregator instead of serving zero-filled tiles for un-rolled days.
*
* v1.4.39 hotfix (QA F-H-01): the previous "any row exists" probe
* could short-circuit to the rollup path while only the first few days
* had been backfilled, exposing zero-filled tiles for the un-rolled
* days until the boot fold completed.
*/
export async function hasMedicationComplianceCoverage(
userId: string,
days: number,
tz: string | null | undefined,
now: Date = new Date(),
): Promise<boolean> {
const safeTz = safeTimezone(tz);
const oldestKey = userDayKey(
new Date(now.getTime() - (days - 1) * 86_400_000),
safeTz,
);
const oldestStart = startOfDayUtcInTz(oldestKey, safeTz);
// Single SQL aggregate: count DISTINCT rolled days vs DISTINCT
// event-days in window. The event side is anchored on the same
// user-tz day-key (`to_char(... AT TIME ZONE $tz, 'YYYY-MM-DD')`) so
// the comparison stays apples-to-apples even on DST boundaries.
const result = await prisma.$queryRaw<
Array<{ rolled_days: bigint; event_days: bigint }>
>`
SELECT
(
SELECT COUNT(DISTINCT "day")::bigint
FROM "medication_compliance_rollups"
WHERE "user_id" = ${userId}
AND "day" >= ${oldestKey}
) AS rolled_days,
(
SELECT COUNT(DISTINCT to_char("scheduled_for" AT TIME ZONE ${safeTz}, 'YYYY-MM-DD'))::bigint
FROM "medication_intake_events"
WHERE "user_id" = ${userId}
AND "scheduled_for" >= ${oldestStart}
) AS event_days
`;
if (result.length === 0) return true;
const { rolled_days, event_days } = result[0];
const rolled = Number(rolled_days);
const events = Number(event_days);
// Zero events in window → trivially covered (the read path produces
// a zero-filled trailing window from the empty rollup table).
if (events === 0) return true;
return rolled >= events;
}
/**
* Full-fold helper. Walks every `(medicationId, day)` pair the user
* has logged across the trailing `days` window and upserts each
* rollup row. The boot-time backfill worker calls this once per
* uncovered user; the route's lazy fallback enqueues it when the
* coverage probe returns false.
*
* Bounded SQL: one `DISTINCT (medicationId, dayKey)` $queryRaw scan
* over the trailing window, then one upsert per row. The dayKey is
* computed in Postgres via `to_char(... AT TIME ZONE $tz)` so we
* don't have to ship millions of rows back to Node.
*/
export async function recomputeUserMedicationCompliance(
userId: string,
days: number = MEDICATION_COMPLIANCE_BACKFILL_DAYS,
tz: string | null | undefined = null,
): Promise<{ rowsUpserted: number; durationMs: number }> {
const startedAt = Date.now();
const safeTz = safeTimezone(tz);
const now = new Date();
const oldestKey = userDayKey(
new Date(now.getTime() - (days - 1) * 86_400_000),
safeTz,
);
const oldestStart = startOfDayUtcInTz(oldestKey, safeTz);
// Distinct (medication_id, dayKey) pairs the user has any intake
// event for inside the trailing window. `to_char(... AT TIME ZONE
// $tz)` keeps the day-bucket on the user's wall-clock.
const pairs = await prisma.$queryRaw<
Array<{ medication_id: string; day: string }>
>`
SELECT DISTINCT
"medication_id",
to_char("scheduled_for" AT TIME ZONE ${safeTz}, 'YYYY-MM-DD') AS "day"
FROM "medication_intake_events"
WHERE "user_id" = ${userId}
AND "scheduled_for" >= ${oldestStart}
`;
let rowsUpserted = 0;
for (const pair of pairs) {
await recomputeMedicationComplianceForDay(
userId,
pair.medication_id,
pair.day,
safeTz,
);
rowsUpserted += 1;
}
return { rowsUpserted, durationMs: Date.now() - startedAt };
}
/**
* Fire-and-forget warm-up — kept around for symmetry with
* `ensureUserRollupsFresh` even though the on-write hook fully owns
* coverage in steady state. The route currently doesn't call this
* directly; the coverage-probe + boot-backfill cascade handles the
* cold-mount case.
*/
export async function ensureUserMedicationComplianceFresh(
userId: string,
days: number = MEDICATION_COMPLIANCE_BACKFILL_DAYS,
tz: string | null | undefined = null,
): Promise<{ recomputed: boolean }> {
try {
if (await hasMedicationComplianceCoverage(userId, days, tz)) {
return { recomputed: false };
}
await recomputeUserMedicationCompliance(userId, days, tz);
return { recomputed: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
medication_compliance_rollup_refresh_failed: true,
medication_compliance_rollup_refresh_error: message,
},
});
console.error(
"[medication-compliance-rollups] ensure-fresh failed:",
message,
);
return { recomputed: false };
}
}
/**
* v1.4.39 hotfix (QA F-SEC-M-01) — user-scoped enqueue helper for the
* request-path coverage-miss fallback.
*
* Mirrors `ensureUserMoodRollupsFresh`: the route fires this when its
* coverage probe returned false so the caller's account picks up a
* targeted boot-backfill job, instead of running a cluster-wide
* `LEFT JOIN` over `medication_intake_events × medication_compliance_rollups`
* on every authenticated coverage-miss request. The cluster-wide scan
* was a soft DoS amplifier — an authenticated user iterating
* coverage-miss requests could drive a multi-tenant table scan on
* every hit.
*
* The boot-time discovery helper below stays cluster-wide (it runs
* once per worker boot, not per request).
*/
export async function enqueueUserMedicationComplianceBackfill(
userId: string,
): Promise<{ enqueued: boolean; error: string | null }> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: false, error: null };
}
try {
const payload: MedicationComplianceBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
payload,
{
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
singletonKey: `medication-compliance-boot-backfill|${userId}`,
},
);
return { enqueued: jobId !== null && jobId !== undefined, error: null };
} catch (err) {
return {
enqueued: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Boot-time enqueue helper — mirrors v1.4.35.1
* `enqueueBootTimeRollupBackfill`. Finds users with intake events but
* zero compliance-rollup coverage for the trailing window and enqueues
* one full-fold per account onto `MEDICATION_COMPLIANCE_BACKFILL_QUEUE`.
*
* Idempotent across reboots: once an account has at least one rollup
* row inside the trailing window the discovery query drops them.
* pg-boss `singletonKey` coalesces duplicate sends.
*/
export async function enqueueBootTimeMedicationComplianceBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
// Trailing window cutoff for the existence probe — `scheduled_for`
// older than this is not what the read path consults.
const cutoff = new Date(
Date.now() - MEDICATION_COMPLIANCE_BACKFILL_DAYS * 86_400_000,
);
const users = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT DISTINCT mie."user_id" AS id
FROM "medication_intake_events" mie
LEFT JOIN "medication_compliance_rollups" r
ON r."user_id" = mie."user_id"
AND r."medication_id" = mie."medication_id"
WHERE mie."scheduled_for" >= ${cutoff}
AND r."day" IS NULL
`;
if (users.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { id } of users) {
const payload: MedicationComplianceBackfillPayload = {
userId: id,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
payload,
{
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
singletonKey: `medication-compliance-boot-backfill|${id}`,
},
);
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
+437
View File
@@ -0,0 +1,437 @@
/**
* v1.4.39 W-MOOD — unit tests for the persistent mood-rollup
* populator.
*
* `prisma` is mocked at the module level so we can pin:
* - the DAY-bucket sync recompute writes through the upsert path,
* - `recomputeMoodBucketsForEntry` is idempotent under re-run,
* - `enqueueMoodRollupRecompute` calls `boss.send` with the
* documented queue + singleton-key shape, and is a silent no-op
* when no boss is attached,
* - `ensureUserMoodRollupsFresh` short-circuits on fresh data and
* dedups concurrent callers onto one in-flight promise,
* - the boot-time backfill discovers users with mood entries but
* no rollup coverage and enqueues one job per uncovered user.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// vi.mock factories run BEFORE module-level statements due to hoisting,
// so the mock fns must live inside the factory closure. We re-export
// them through `vi.hoisted` so the test body can reach them after.
const mocks = vi.hoisted(() => ({
queryRaw: vi.fn(),
queryRawUnsafe: vi.fn(),
executeRaw: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
findFirstMoodEntry: vi.fn(),
bossSend: vi.fn(),
getGlobalBossMock: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: mocks.queryRaw,
$queryRawUnsafe: mocks.queryRawUnsafe,
$executeRaw: mocks.executeRaw,
moodEntryRollup: {
upsert: mocks.upsert,
deleteMany: mocks.deleteMany,
findFirst: mocks.findFirst,
findMany: mocks.findMany,
},
moodEntry: {
findFirst: mocks.findFirstMoodEntry,
},
},
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => mocks.getGlobalBossMock(),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { annotate } from "@/lib/logging/context";
const {
queryRaw,
queryRawUnsafe,
executeRaw,
upsert,
deleteMany,
findFirst,
findFirstMoodEntry,
bossSend,
getGlobalBossMock,
} = mocks;
// `findMany` is wired in so indirect lookups don't throw; the
// populator tests below exercise the `$queryRawUnsafe` + upsert
// paths so the reference stays unused inside this file.
void mocks.findMany;
void queryRaw;
import {
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
MOOD_ROLLUP_RECOMPUTE_QUEUE,
_resetEnsureUserMoodRollupsFreshInFlightForTests,
enqueueBootTimeMoodRollupBackfill,
enqueueMoodRollupRecompute,
ensureUserMoodRollupsFresh,
recomputeMoodBucketsForEntry,
} from "../rollups";
beforeEach(() => {
queryRaw.mockReset();
queryRawUnsafe.mockReset();
executeRaw.mockReset();
executeRaw.mockResolvedValue(0);
upsert.mockReset();
deleteMany.mockReset();
findFirst.mockReset();
mocks.findMany.mockReset();
findFirstMoodEntry.mockReset();
bossSend.mockReset();
getGlobalBossMock.mockReset();
// v1.4.39 — clear the per-userId in-flight map so a previous
// test's resolved promise does not short-circuit the next test.
_resetEnsureUserMoodRollupsFreshInFlightForTests();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("recomputeMoodBucketsForEntry", () => {
function bindValues(call: unknown[]): unknown[] {
return call.slice(1);
}
it("runs the atomic DAY upsert + DELETE pair and fires WEEK/MONTH/YEAR enqueues", async () => {
// QA F-H-02 (v1.4.39): the DAY pass is now a single atomic SQL
// statement (INSERT … SELECT … ON CONFLICT) plus a paired DELETE
// that gates on NOT EXISTS. No JS-side aggregate, no per-row
// upsert through the Prisma client.
getGlobalBossMock.mockReturnValue({ send: bossSend });
bossSend.mockResolvedValue("job-id");
await recomputeMoodBucketsForEntry(
"user-1",
new Date("2026-05-10T14:30:00.000Z"),
);
expect(executeRaw).toHaveBeenCalledTimes(2);
const insertBinds = bindValues(executeRaw.mock.calls[0]);
expect(insertBinds).toContain("user-1");
// bucket_start is bound twice (INSERT row + INSERT SELECT predicate
// are both inside the same CTE shape via the same parameter).
const dayStart = new Date("2026-05-10T00:00:00.000Z");
expect(
(insertBinds.filter((b) => b instanceof Date) as Date[]).some(
(d) => d.getTime() === dayStart.getTime(),
),
).toBe(true);
// QA F-H-03 — the enqueue is fire-and-forget; the response path
// never awaits the boss.send call. Wait a tick for the
// floating promise to flush before asserting.
await new Promise((r) => setImmediate(r));
expect(bossSend).toHaveBeenCalledTimes(3);
for (const call of bossSend.mock.calls) {
expect(call[0]).toBe(MOOD_ROLLUP_RECOMPUTE_QUEUE);
expect(call[1].userId).toBe("user-1");
expect(["WEEK", "MONTH", "YEAR"]).toContain(call[1].granularity);
}
});
it("issues the paired DELETE so a fully-cleared day removes its row", async () => {
getGlobalBossMock.mockReturnValue(null);
await recomputeMoodBucketsForEntry(
"user-1",
new Date("2026-05-10T14:30:00.000Z"),
);
// The DELETE always fires; its NOT EXISTS predicate matches zero
// rows on the populated-day branch and matches the row on the
// emptied-day branch. We can only assert the second statement
// fired here.
expect(executeRaw).toHaveBeenCalledTimes(2);
expect(deleteMany).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
});
it("is idempotent under re-run for the same (user, day)", async () => {
getGlobalBossMock.mockReturnValue(null);
await recomputeMoodBucketsForEntry(
"user-1",
new Date("2026-05-10T14:30:00.000Z"),
);
await recomputeMoodBucketsForEntry(
"user-1",
new Date("2026-05-10T14:30:00.000Z"),
);
expect(executeRaw).toHaveBeenCalledTimes(4);
// Bind values for the INSERT statement match across both runs.
expect(bindValues(executeRaw.mock.calls[0])).toEqual(
bindValues(executeRaw.mock.calls[2]),
);
// Bind values for the DELETE statement match across both runs.
expect(bindValues(executeRaw.mock.calls[1])).toEqual(
bindValues(executeRaw.mock.calls[3]),
);
});
it("commits the strictest aggregate under concurrent recompute calls", async () => {
// QA F-H-02 race pin: two concurrent recomputes for the same
// (user, day) must both go through the atomic SQL path so each
// statement re-aggregates a snapshot taken after the prior commit
// released its row lock.
getGlobalBossMock.mockReturnValue(null);
await Promise.all([
recomputeMoodBucketsForEntry(
"user-1",
new Date("2026-05-10T14:30:00.000Z"),
),
recomputeMoodBucketsForEntry(
"user-1",
new Date("2026-05-10T14:30:00.000Z"),
),
]);
// 2 callers × 2 statements (INSERT + DELETE) each.
expect(executeRaw).toHaveBeenCalledTimes(4);
// No legacy JS-side aggregate ($queryRawUnsafe) fired on the DAY
// pass; no Prisma-API upsert / deleteMany were issued either.
expect(queryRawUnsafe).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
expect(deleteMany).not.toHaveBeenCalled();
});
it("does not await the WEEK/MONTH/YEAR enqueue on the write-path response", async () => {
// QA F-H-03 (v1.4.39): the outer Promise.all is fire-and-forget so
// a slow pg-boss send never serialises the user's mood-write
// response. Pin that the helper resolves before the enqueue
// promise settles.
let resolveEnqueue: (() => void) | null = null;
const enqueueGate = new Promise<string>((resolve) => {
resolveEnqueue = () => resolve("job-late");
});
getGlobalBossMock.mockReturnValue({ send: () => enqueueGate });
await recomputeMoodBucketsForEntry(
"user-1",
new Date("2026-05-10T14:30:00.000Z"),
);
// The helper has resolved even though the boss.send promise is
// still pending. Resolve it after the assertion so the test does
// not leak a pending promise across the suite.
resolveEnqueue!();
await enqueueGate;
});
});
describe("enqueueMoodRollupRecompute", () => {
it("is a silent no-op when no boss is attached", async () => {
getGlobalBossMock.mockReturnValue(null);
await enqueueMoodRollupRecompute({
userId: "user-1",
granularity: "WEEK",
from: new Date("2026-05-04T00:00:00.000Z"),
to: new Date("2026-05-11T00:00:00.000Z"),
});
expect(bossSend).not.toHaveBeenCalled();
});
it("sends with the documented queue + singleton-key shape", async () => {
getGlobalBossMock.mockReturnValue({ send: bossSend });
bossSend.mockResolvedValue("job-1");
const from = new Date("2026-05-04T00:00:00.000Z");
await enqueueMoodRollupRecompute({
userId: "user-9",
granularity: "WEEK",
from,
to: new Date("2026-05-11T00:00:00.000Z"),
});
expect(bossSend).toHaveBeenCalledTimes(1);
const [queue, payload, opts] = bossSend.mock.calls[0];
expect(queue).toBe(MOOD_ROLLUP_RECOMPUTE_QUEUE);
expect(payload.userId).toBe("user-9");
expect(payload.granularity).toBe("WEEK");
expect(payload.from).toBe(from.toISOString());
expect(opts.singletonKey).toBe(`user-9|WEEK|${from.toISOString()}`);
});
});
describe("ensureUserMoodRollupsFresh", () => {
it("is a no-op when the user has no mood entries", async () => {
findFirst.mockResolvedValueOnce(null);
findFirstMoodEntry.mockResolvedValueOnce(null);
const result = await ensureUserMoodRollupsFresh("user-1");
expect(result.recomputed).toBe(false);
expect(queryRawUnsafe).not.toHaveBeenCalled();
});
it("is a no-op when the rollup is already ahead of the newest mood entry", async () => {
const rollupAt = new Date("2026-05-10T12:00:00.000Z");
const entryAt = new Date("2026-05-10T11:00:00.000Z");
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
findFirstMoodEntry.mockResolvedValueOnce({
updatedAt: entryAt,
moodLoggedAt: entryAt,
});
const result = await ensureUserMoodRollupsFresh("user-1");
expect(result.recomputed).toBe(false);
expect(queryRawUnsafe).not.toHaveBeenCalled();
});
it("triggers a DAY-window recompute when stale", async () => {
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
const entryAt = new Date("2026-05-10T12:00:00.000Z");
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
findFirstMoodEntry.mockResolvedValueOnce({
updatedAt: entryAt,
moodLoggedAt: entryAt,
});
queryRawUnsafe.mockResolvedValueOnce([]);
const result = await ensureUserMoodRollupsFresh("user-1");
expect(result.recomputed).toBe(true);
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
});
it("swallows populator errors so the read path never fails", async () => {
findFirst.mockRejectedValueOnce(new Error("pool exhausted"));
findFirstMoodEntry.mockRejectedValueOnce(new Error("pool exhausted"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await ensureUserMoodRollupsFresh("user-1");
expect(result.recomputed).toBe(false);
consoleSpy.mockRestore();
});
it("dedups concurrent callers for the same userId onto one in-flight promise", async () => {
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
const entryAt = new Date("2026-05-10T12:00:00.000Z");
findFirst.mockResolvedValue({ computedAt: rollupAt });
findFirstMoodEntry.mockResolvedValue({
updatedAt: entryAt,
moodLoggedAt: entryAt,
});
queryRawUnsafe.mockResolvedValue([]);
const [a, b, c] = await Promise.all([
ensureUserMoodRollupsFresh("user-1"),
ensureUserMoodRollupsFresh("user-1"),
ensureUserMoodRollupsFresh("user-1"),
]);
expect(a.recomputed).toBe(true);
expect(b.recomputed).toBe(true);
expect(c.recomputed).toBe(true);
expect(queryRawUnsafe).toHaveBeenCalledTimes(1);
expect(findFirst).toHaveBeenCalledTimes(1);
expect(findFirstMoodEntry).toHaveBeenCalledTimes(1);
});
it("annotates the failure when the inner recompute throws", async () => {
const rollupAt = new Date("2026-05-10T10:00:00.000Z");
const entryAt = new Date("2026-05-10T12:00:00.000Z");
findFirst.mockResolvedValueOnce({ computedAt: rollupAt });
findFirstMoodEntry.mockResolvedValueOnce({
updatedAt: entryAt,
moodLoggedAt: entryAt,
});
queryRawUnsafe.mockRejectedValueOnce(new Error("deadlock detected"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await ensureUserMoodRollupsFresh("user-1");
expect(result.recomputed).toBe(false);
expect(annotate).toHaveBeenCalledWith({
meta: {
mood_rollup_refresh_failed: true,
mood_rollup_refresh_error: "deadlock detected",
},
});
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("enqueueBootTimeMoodRollupBackfill", () => {
it("is a silent no-op when no boss is attached", async () => {
getGlobalBossMock.mockReturnValue(null);
const result = await enqueueBootTimeMoodRollupBackfill();
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
expect(queryRaw).not.toHaveBeenCalled();
expect(bossSend).not.toHaveBeenCalled();
});
it("enqueues one full-fold job per user with mood entries but no rollups", async () => {
getGlobalBossMock.mockReturnValue({ send: bossSend });
queryRaw.mockResolvedValueOnce([
{ id: "user-a" },
{ id: "user-b" },
{ id: "user-c" },
]);
bossSend
.mockResolvedValueOnce("job-a")
.mockResolvedValueOnce("job-b")
.mockResolvedValueOnce("job-c");
const result = await enqueueBootTimeMoodRollupBackfill();
expect(result).toEqual({ enqueued: 3, skipped: 0, error: null });
expect(bossSend).toHaveBeenCalledTimes(3);
for (const call of bossSend.mock.calls) {
expect(call[0]).toBe(MOOD_ROLLUP_FULL_BACKFILL_QUEUE);
}
expect(bossSend.mock.calls[0][2].singletonKey).toBe(
"mood-boot-backfill|user-a",
);
expect(bossSend.mock.calls[1][2].singletonKey).toBe(
"mood-boot-backfill|user-b",
);
expect(bossSend.mock.calls[2][2].singletonKey).toBe(
"mood-boot-backfill|user-c",
);
});
it("counts a `boss.send` returning null as 'skipped' (singleton coalesce)", async () => {
getGlobalBossMock.mockReturnValue({ send: bossSend });
queryRaw.mockResolvedValueOnce([{ id: "user-a" }, { id: "user-b" }]);
bossSend.mockResolvedValueOnce(null).mockResolvedValueOnce("job-b");
const result = await enqueueBootTimeMoodRollupBackfill();
expect(result).toEqual({ enqueued: 1, skipped: 1, error: null });
});
it("returns the error message when the discovery query throws", async () => {
getGlobalBossMock.mockReturnValue({ send: bossSend });
queryRaw.mockRejectedValueOnce(new Error("pool exhausted"));
const result = await enqueueBootTimeMoodRollupBackfill();
expect(result.enqueued).toBe(0);
expect(result.error).toBe("pool exhausted");
expect(bossSend).not.toHaveBeenCalled();
});
it("returns { enqueued: 0 } when no users need backfill", async () => {
getGlobalBossMock.mockReturnValue({ send: bossSend });
queryRaw.mockResolvedValueOnce([]);
const result = await enqueueBootTimeMoodRollupBackfill();
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
expect(bossSend).not.toHaveBeenCalled();
});
});
+670
View File
@@ -0,0 +1,670 @@
/**
* v1.4.39 W-MOOD — persistent mood-rollup populator.
*
* Sits behind the 60-s `caches.moodAnalytics` LRU as the second cache
* tier on `/api/mood/analytics`. One row per
* `(userId, granularity, bucketStart)` in `mood_entry_rollups`,
* carrying `count` / `mean` / `min_score` / `max_score` / `sd`
* (population) folded via Postgres `AVG` / `MIN` / `MAX` / `STDDEV_POP`.
*
* The audit at `.planning/round-v1438-perf-analysis.md` §2.3 traced
* the 12.7 s cold mount on `/api/mood/analytics` to the unbounded
* `prisma.moodEntry.findMany({ where: { userId } })` that walked
* every mood row the user has ever written into Node only to
* bucket-average them in JS. This module mirrors the well-trodden
* `src/lib/measurements/rollups.ts` pattern so the read path can swap
* to a bounded `prisma.moodEntryRollup.findMany` with the daily mean
* already materialised.
*
* Write hooks:
* - Every mood-entry create/update/delete fires
* `recomputeMoodBucketsForEntry`. The DAY pass runs inline
* (cheap — single `(user, day)` aggregate) so the next read sees
* the fresh row without waiting on pg-boss.
* - WEEK / MONTH / YEAR are enqueued onto `mood-rollup-recompute`
* for the worker to fold. No current reader consumes them; the
* buckets exist so future cross-granularity readers ship without
* a backfill step.
* - Bulk endpoints (`/api/mood-entries/bulk`, restore) call
* `recomputeUserMoodRollups(userId)` once at the end of the
* batch instead of firing N per-row hooks.
*
* Reads:
* - `readMoodDayRollups(userId, since)` returns the persisted DAY
* rows. The reader-side fallback in `/api/mood/analytics` checks
* the rollup count and falls through to the legacy live findMany
* once when the user has mood entries but zero rollup rows;
* `enqueueBootTimeMoodRollupBackfill` mints the rollups on the
* next worker boot so subsequent reads converge on the fast path.
*/
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
import type {
Prisma,
PrismaClient,
RollupGranularity,
} from "@/generated/prisma/client";
/** Granularities that recompute asynchronously via pg-boss. */
const ASYNC_GRANULARITIES: RollupGranularity[] = ["WEEK", "MONTH", "YEAR"];
/** All granularities the rollup table tracks. */
export const ALL_MOOD_GRANULARITIES: RollupGranularity[] = [
"DAY",
"WEEK",
"MONTH",
"YEAR",
];
/** pg-boss queue name for per-bucket recompute jobs. */
export const MOOD_ROLLUP_RECOMPUTE_QUEUE = "mood-rollup-recompute";
/** Worker concurrency cap — recomputes are read-heavy on Postgres. */
export const MOOD_ROLLUP_RECOMPUTE_CONCURRENCY = 2;
/** pg-boss queue name for the boot-time backfill discovery. */
export const MOOD_ROLLUP_FULL_BACKFILL_QUEUE = "mood-rollup-full-backfill";
/** Boot-fold worker concurrency. Serial so we don't crowd the pool. */
export const MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY = 1;
/** Payload the worker queue carries. */
export interface MoodRollupRecomputePayload {
userId: string;
granularity: RollupGranularity;
/** ISO-8601 — the worker parses through `new Date(value)`. */
from: string;
/** ISO-8601 — the worker parses through `new Date(value)`. */
to: string;
/** Wall-clock kick-off for debugging only. */
enqueuedAt: string;
}
/** Payload the boot-time backfill helper enqueues. */
export interface MoodRollupFullBackfillPayload {
userId: string;
/** Wall-clock kick-off for debugging only. */
enqueuedAt: string;
}
/** Postgres `date_trunc` unit per granularity. */
const DATE_TRUNC_UNIT: Record<RollupGranularity, string> = {
DAY: "day",
WEEK: "week",
MONTH: "month",
YEAR: "year",
};
/** Raw row shape returned by the aggregate `$queryRawUnsafe`. */
interface MoodRollupRow {
bucket_start: Date;
count: bigint;
mean: number | null;
min_score: number | null;
max_score: number | null;
sd: number | null;
}
/**
* Tx-or-client surface the writer accepts. Mirrors the
* `measurement-rollups` write-path contract: when called from inside a
* `prisma.$transaction(async tx => …)` block, callers pass the `tx`
* client so the rollup write commits atomically with the parent
* mutation.
*/
type PrismaTxOrClient = Pick<
PrismaClient,
"$queryRawUnsafe" | "$executeRaw" | "moodEntryRollup"
>;
/**
* Recompute the rollup buckets for one user. Bounded by the optional
* `granularities` / `from` / `to` filters; defaults cover every
* granularity and the trailing 5 years.
*
* Idempotent: rows are upserted under the
* `(userId, granularity, bucketStart)` composite primary key, so
* re-running the call is a no-op for unchanged buckets and a refresh
* for changed ones.
*/
export async function recomputeUserMoodRollups(
userId: string,
opts: {
granularities?: RollupGranularity[];
from?: Date;
to?: Date;
} = {},
): Promise<{ rowsUpserted: number; durationMs: number }> {
const startedAt = Date.now();
const granularities = opts.granularities ?? ALL_MOOD_GRANULARITIES;
const fiveYearsAgo = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000);
const from = opts.from ?? fiveYearsAgo;
const to = opts.to ?? new Date();
let rowsUpserted = 0;
for (const granularity of granularities) {
const rows = await runMoodRollupAggregate(prisma, {
userId,
granularity,
from,
to,
});
if (rows.length === 0) continue;
rowsUpserted += await persistMoodRollupRows(prisma, userId, granularity, rows);
}
return { rowsUpserted, durationMs: Date.now() - startedAt };
}
/**
* Synchronous DAY-bucket recompute for the write-path hooks plus an
* async enqueue of the WEEK / MONTH / YEAR recomputes.
*
* Scoped to the affected `(userId, day)` tuple — cheap, and avoids
* invalidating buckets that haven't changed.
*
* Pass `tx` when called from inside a `prisma.$transaction` block so
* the rollup write commits atomically with the parent mutation.
* Otherwise the global `prisma` client is used.
*/
export async function recomputeMoodBucketsForEntry(
userId: string,
moodLoggedAt: Date,
tx?: PrismaTxOrClient,
): Promise<void> {
const client = tx ?? prisma;
// DAY pass — synchronous. v1.4.39 hotfix (QA F-H-02): one atomic SQL
// statement re-aggregates inside the upsert subquery, closing the
// race window where two concurrent writes for the same (user, day)
// could interleave A-SELECT → B-SELECT → B-UPSERT (correct) →
// A-UPSERT (stale N-1). The trailing DELETE handles the "all entries
// for the day were removed" case in the same shot.
const dayStart = startOfUtcDay(moodLoggedAt);
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
await client.$executeRaw`
WITH aggregate AS (
SELECT
COUNT(*)::int AS count,
AVG(m."score")::double precision AS mean,
MIN(m."score")::integer AS min_score,
MAX(m."score")::integer AS max_score,
STDDEV_POP(m."score")::double precision AS sd
FROM "mood_entries" m
WHERE m."user_id" = ${userId}
AND m."mood_logged_at" >= ${dayStart}
AND m."mood_logged_at" < ${dayEnd}
)
INSERT INTO "mood_entry_rollups"
("user_id", "granularity", "bucket_start", "count", "mean", "min_score", "max_score", "sd", "computed_at")
SELECT
${userId},
'DAY',
${dayStart},
aggregate.count,
aggregate.mean,
aggregate.min_score,
aggregate.max_score,
aggregate.sd,
NOW()
FROM aggregate
WHERE aggregate.count > 0
ON CONFLICT ("user_id", "granularity", "bucket_start") DO UPDATE
SET "count" = EXCLUDED."count",
"mean" = EXCLUDED."mean",
"min_score" = EXCLUDED."min_score",
"max_score" = EXCLUDED."max_score",
"sd" = EXCLUDED."sd",
"computed_at" = EXCLUDED."computed_at"
`;
await client.$executeRaw`
DELETE FROM "mood_entry_rollups"
WHERE "user_id" = ${userId}
AND "granularity" = 'DAY'
AND "bucket_start" = ${dayStart}
AND NOT EXISTS (
SELECT 1
FROM "mood_entries" m
WHERE m."user_id" = ${userId}
AND m."mood_logged_at" >= ${dayStart}
AND m."mood_logged_at" < ${dayEnd}
)
`;
// WEEK / MONTH / YEAR — fire-and-forget enqueue. QA F-H-03 (v1.4.39):
// the outer await on this Promise.all used to serialise the write-path
// response on pg-boss latency. The DAY pass the read path depends on
// has already committed synchronously above; the queue is a hint, not
// a write-path invariant, so dropping the await keeps the response
// off the pg-boss critical path. Errors stay caught inside
// `enqueueMoodRollupRecompute` so the unhandled-rejection logger
// captures any pg-boss failure.
void Promise.all(
ASYNC_GRANULARITIES.map((granularity) => {
const { from, to } = bucketSpan(moodLoggedAt, granularity);
return enqueueMoodRollupRecompute({ userId, granularity, from, to }).catch(
(err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
mood_rollup_enqueue_failed: true,
mood_rollup_enqueue_error: message,
},
});
console.error("[mood-rollups] async enqueue failed:", message);
},
);
}),
);
}
/**
* Best-effort pg-boss enqueue. Silent no-op when no boss is attached
* (route running in a context without the worker — typically the test
* suite). The reader-side fall-through still produces fresh data on a
* cache miss, so missing the enqueue is at-most a one-read cost
* rather than a correctness bug.
*/
export async function enqueueMoodRollupRecompute(input: {
userId: string;
granularity: RollupGranularity;
from: Date;
to: Date;
}): Promise<void> {
const boss = getGlobalBoss();
if (!boss) return;
const payload: MoodRollupRecomputePayload = {
userId: input.userId,
granularity: input.granularity,
from: input.from.toISOString(),
to: input.to.toISOString(),
enqueuedAt: new Date().toISOString(),
};
await boss.send(MOOD_ROLLUP_RECOMPUTE_QUEUE, payload, {
retryLimit: 3,
retryDelay: 30,
retryBackoff: true,
// Coalesce duplicate enqueues for the same bucket key — two writes
// inside the same week share one queued job.
singletonKey: `${input.userId}|${input.granularity}|${input.from.toISOString()}`,
});
}
/**
* Read DAY-granularity rollup rows for one user, sorted ascending by
* `bucketStart`. The read-path swap in `/api/mood/analytics` consumes
* these directly without any extra Node-side aggregation.
*
* `since` is an inclusive lower bound. Pass a 5-year-old `Date` from
* the route to mirror the legacy "unbounded" semantics; future
* consumers can narrow the window cheaply because the rollup table
* carries one row per day rather than one per entry.
*/
export async function readMoodDayRollups(
userId: string,
since: Date,
): Promise<
Array<{
bucketStart: Date;
count: number;
mean: number;
minScore: number;
maxScore: number;
sd: number | null;
computedAt: Date;
}>
> {
const rows = await prisma.moodEntryRollup.findMany({
where: {
userId,
granularity: "DAY",
bucketStart: { gte: since },
},
orderBy: { bucketStart: "asc" },
});
return rows.map((r) => ({
bucketStart: r.bucketStart,
count: r.count,
mean: r.mean,
minScore: r.minScore,
maxScore: r.maxScore,
sd: r.sd,
computedAt: r.computedAt,
}));
}
/**
* v1.4.39 — fire-and-forget warm-up for the DAY tier.
*
* Contract:
* - If at least one DAY rollup row exists for the user and it is
* fresh (newest rollup `computed_at` ≥ newest mood entry
* `updated_at`), this is a no-op.
* - Otherwise the helper kicks off a full
* `recomputeUserMoodRollups` over the trailing 5-year window so
* the next read of `/api/mood/analytics` lands on the rollup
* tier. Mood entries are typically 1/day so the recompute is
* bounded to a few thousand rows even for power users — cheaper
* than the 12.7 s legacy live walk by orders of magnitude.
* - Best-effort: thrown errors are swallowed so the read path
* never fails because of a populator hiccup.
* - Concurrent callers for the same `userId` share one in-flight
* promise so a cold fan-out can never queue N parallel
* recompute runs.
*/
const ensureUserMoodRollupsFreshInFlight = new Map<
string,
Promise<{ recomputed: boolean }>
>();
export async function ensureUserMoodRollupsFresh(
userId: string,
): Promise<{ recomputed: boolean }> {
const existing = ensureUserMoodRollupsFreshInFlight.get(userId);
if (existing) return existing;
const inflight = ensureUserMoodRollupsFreshImpl(userId).finally(() => {
ensureUserMoodRollupsFreshInFlight.delete(userId);
});
ensureUserMoodRollupsFreshInFlight.set(userId, inflight);
return inflight;
}
/**
* Test-only handle on the in-flight map. Mirrors the
* `_resetEnsureUserRollupsFreshInFlightForTests` pattern in
* `measurements/rollups.ts` — production code never calls this.
*/
export function _resetEnsureUserMoodRollupsFreshInFlightForTests(): void {
ensureUserMoodRollupsFreshInFlight.clear();
}
async function ensureUserMoodRollupsFreshImpl(
userId: string,
): Promise<{ recomputed: boolean }> {
try {
const [newestRollup, newestEntry] = await Promise.all([
prisma.moodEntryRollup.findFirst({
where: { userId, granularity: "DAY" },
orderBy: { computedAt: "desc" },
select: { computedAt: true },
}),
prisma.moodEntry.findFirst({
where: { userId },
orderBy: { updatedAt: "desc" },
select: { updatedAt: true, moodLoggedAt: true },
}),
]);
if (!newestEntry) {
// No mood entries — rollups are trivially fresh.
return { recomputed: false };
}
const watermark =
newestEntry.updatedAt > newestEntry.moodLoggedAt
? newestEntry.updatedAt
: newestEntry.moodLoggedAt;
if (newestRollup && watermark <= newestRollup.computedAt) {
return { recomputed: false };
}
// Stale — full 5-year DAY fold. Bounded because mood entries are
// typically 1/day, so even a multi-year power user materialises a
// few thousand rows tops.
await recomputeUserMoodRollups(userId, { granularities: ["DAY"] });
return { recomputed: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
mood_rollup_refresh_failed: true,
mood_rollup_refresh_error: message,
},
});
console.error("[mood-rollups] ensureUserMoodRollupsFresh failed:", message);
return { recomputed: false };
}
}
/**
* v1.4.39 — boot-time enqueue helper. Finds users with mood entries
* but zero `mood_entry_rollups` rows and enqueues one full-fold per
* account onto `MOOD_ROLLUP_FULL_BACKFILL_QUEUE`. Designed to be
* called once at worker boot so every self-hosted instance auto-
* converges on the persistent rollup tier without operator action.
*
* Idempotent across reboots:
* - the discovery query matches accounts with mood entries but
* zero rollup rows; once the fold completes the user drops off
* the list
* - pg-boss `singletonKey` coalesces duplicate sends within the
* queue, so a fast restart while a backfill is queued doesn't
* double up
*
* Best-effort: errors are swallowed and reported through the return
* value so the worker boot never fails because of a backfill miss.
*/
export async function enqueueBootTimeMoodRollupBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
// Users with mood entries but no rollup coverage. Anchored on the
// per-user `DISTINCT user_id` set so the planner uses the
// `(user_id, date)` index path; the LEFT JOIN onto
// `mood_entry_rollups` uses the
// `(user_id, granularity, bucket_start)` composite primary key.
// `r."bucket_start" IS NULL` (any column from the right side of
// the LEFT JOIN) marks the unmatched users.
const users = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT DISTINCT mt."user_id" AS id
FROM (
SELECT DISTINCT m."user_id"
FROM mood_entries m
) mt
LEFT JOIN mood_entry_rollups r
ON r."user_id" = mt."user_id"
AND r."granularity" = 'DAY'
WHERE r."bucket_start" IS NULL
`;
if (users.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { id } of users) {
const payload: MoodRollupFullBackfillPayload = {
userId: id,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(MOOD_ROLLUP_FULL_BACKFILL_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
// Coalesce: if a backfill for this user is already queued and
// we restart, pg-boss returns null instead of duplicating.
singletonKey: `mood-boot-backfill|${id}`,
});
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
// ─── internals ────────────────────────────────────────────
/**
* Pull the raw aggregates from Postgres. Groups by
* `date_trunc(<granularity>, mood_logged_at)` and folds the stats
* with `STDDEV_POP`. The `date_trunc` unit literal must be inlined
* because Postgres rejects a parameterised expression in a GROUP BY;
* the value comes from a closed enum (DATE_TRUNC_UNIT) so there's no
* injection surface — we still assert against the whitelist before
* splicing.
*/
async function runMoodRollupAggregate(
client: PrismaTxOrClient,
input: {
userId: string;
granularity: RollupGranularity;
from: Date;
to: Date;
},
): Promise<MoodRollupRow[]> {
const truncUnit = DATE_TRUNC_UNIT[input.granularity];
if (!["day", "week", "month", "year"].includes(truncUnit)) {
throw new Error(`unexpected granularity: ${input.granularity}`);
}
const dateTrunc = `date_trunc('${truncUnit}', m."mood_logged_at")`;
const sql = `
SELECT
${dateTrunc} AS bucket_start,
COUNT(*) AS count,
AVG(m."score")::double precision AS mean,
MIN(m."score")::integer AS min_score,
MAX(m."score")::integer AS max_score,
STDDEV_POP(m."score")::double precision AS sd
FROM mood_entries m
WHERE m."user_id" = $1
AND m."mood_logged_at" >= $2
AND m."mood_logged_at" < $3
GROUP BY ${dateTrunc}
`;
return client.$queryRawUnsafe<MoodRollupRow[]>(
sql,
input.userId,
input.from,
input.to,
);
}
/**
* UPSERT the aggregate rows into `mood_entry_rollups`. Returns the
* number of rows touched. Chunked so a multi-year fold doesn't blow
* the parameter cap on a single round-trip.
*/
async function persistMoodRollupRows(
client: PrismaTxOrClient,
userId: string,
granularity: RollupGranularity,
rows: MoodRollupRow[],
): Promise<number> {
if (rows.length === 0) return 0;
const CHUNK = 500;
let touched = 0;
for (let i = 0; i < rows.length; i += CHUNK) {
const slice = rows.slice(i, i + CHUNK);
// Run each upsert sequentially through the supplied client (which
// may be a transactional handle). Wrapping in `$transaction` here
// would crash when the caller already holds an interactive
// transaction, so we rely on the parent caller (or atomicity of
// the single upsert) for crash safety. The hot path is a single
// row per DAY recompute, so the round-trip cost is negligible.
for (const row of slice) {
const payload = {
count: Number(row.count),
mean: row.mean ?? 0,
minScore: row.min_score ?? 0,
maxScore: row.max_score ?? 0,
sd: row.sd,
computedAt: new Date(),
} satisfies Prisma.MoodEntryRollupUpdateInput;
await client.moodEntryRollup.upsert({
where: {
userId_granularity_bucketStart: {
userId,
granularity,
bucketStart: row.bucket_start,
},
},
create: {
userId,
granularity,
bucketStart: row.bucket_start,
...payload,
},
update: payload,
});
}
touched += slice.length;
}
return touched;
}
/**
* Truncate a `Date` to the start of its UTC day. The rollup table
* stores TIMESTAMPTZ values so we anchor on UTC to keep the bucket
* key deterministic across timezone-shifting consumers — same
* convention as `measurement-rollups`.
*/
function startOfUtcDay(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0),
);
}
/**
* Span of the bucket containing `moodLoggedAt` at the given
* granularity. Mirrors `measurement-rollups` so the WEEK / MONTH /
* YEAR fold queries select the same set of rows the read-side
* `date_trunc` would group on.
*/
function bucketSpan(
moodLoggedAt: Date,
granularity: RollupGranularity,
): { from: Date; to: Date } {
switch (granularity) {
case "DAY": {
const from = startOfUtcDay(moodLoggedAt);
return { from, to: new Date(from.getTime() + 24 * 60 * 60 * 1000) };
}
case "WEEK": {
const day = startOfUtcDay(moodLoggedAt);
// Postgres ISO week: Monday is day 1. JS getUTCDay(): Sunday=0.
const dayOfWeek = day.getUTCDay();
const mondayOffset = (dayOfWeek + 6) % 7;
const from = new Date(
day.getTime() - mondayOffset * 24 * 60 * 60 * 1000,
);
return { from, to: new Date(from.getTime() + 7 * 24 * 60 * 60 * 1000) };
}
case "MONTH": {
const from = new Date(
Date.UTC(moodLoggedAt.getUTCFullYear(), moodLoggedAt.getUTCMonth(), 1),
);
const to = new Date(
Date.UTC(
moodLoggedAt.getUTCFullYear(),
moodLoggedAt.getUTCMonth() + 1,
1,
),
);
return { from, to };
}
case "YEAR": {
const from = new Date(Date.UTC(moodLoggedAt.getUTCFullYear(), 0, 1));
const to = new Date(Date.UTC(moodLoggedAt.getUTCFullYear() + 1, 0, 1));
return { from, to };
}
}
}
+16
View File
@@ -7,6 +7,7 @@ import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
/**
* Sync mood entries from a user's moodLog instance.
@@ -244,5 +245,20 @@ export async function syncMoodLogEntries(
});
await recordSyncSuccess(userId, "moodlog");
// v1.4.39 W-MOOD — re-fold the rollup tier after a sync. The
// sync upserts a batch spanning many days; one bounded
// recompute is cheaper than firing N per-row hooks. Best-effort:
// a rollup failure here must not bubble up and undo the
// recordSyncSuccess we just committed.
if (imported > 0) {
try {
await recomputeUserMoodRollups(userId, { granularities: ["DAY"] });
} catch (err) {
getEvent()?.addWarning(
`Mood rollup recompute failed after sync: ${err}`,
);
}
}
return imported;
}