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:
Marc-André Bombeck committed 2026-05-18 12:20:14 +02:00
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();
});
});
+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,
};
}
@@ -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");
});
});