mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
perf(summaries-slice): wire long-window slice to monthly rollup buckets
Adds `computeLongWindowSummary(userId, type, windowDays)` — the ready-to-use entry point for the v1.5 multi-year trend card and the Coach drawer's "history" tile. Routes the requested window through the largest granularity that still resolves it (90 d \u2192 DAY, 365 d \u2192 MONTH, 1095 d \u2192 YEAR) via `readBestGranularityRollups`, then composes `count / min / max / mean / sum` linearly from the bucket rows. SD / slope / r2 are intentionally omitted because they do not compose across coarser buckets. The existing `computeSummariesSlice` shape is untouched — its windowed columns cap at 90 d so the live SQL path is already DAY-tier optimal. Wiring the all-time aggregate through YEAR buckets would risk staleness on multi-year data because the WEEK / MONTH / YEAR tiers update asynchronously via pg-boss while DAY is kept fresh by the sync write-hook. Deferring that swap to v1.5 (covered in the W-WMY phase report).
This commit is contained in:
1 parent
586def916c
commit
8763b3aaa7
6 files changed
+679
-1
No files matched your search
@@ -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(((
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user