fix(series): follow the source priority ladder in the dense and raw series reads

The rollup tier and sleep resolve every (type, day) to the source the person's
ladder picks before they aggregate. The dense and raw reads of
/api/measurements/series filtered on user and type only, so with a second
provider a day's value blended the two, a reading present in both showed
twice, and the configured ladder had no effect on those charts.

Both reads now take the ladder-winning source per day through the shared
canonicalMeasurementsCte helper, as the other live readers do:

- the day and hour bucket read of the dense kinds (pulse, glucose, HRV, SpO2),
  and the stats strip over its rows;
- the generic raw read (weight, body fat, steps, resting heart rate, ...).

The collapse is per day for the hour grain too, as everywhere else. The helper
gains an optional single-type scope so a series scans one metric, not every row
the person has in the window.

Only a type that has a ladder is collapsed. For a type without one (glucose,
body water, bone mass) the helper breaks the tie by source name, which would
silently hide one of two real sources, so those keep every source as before.
A single-source account reads exactly what is stored.

Tests: a real-database test covering the ladder in both directions, the
per-day fallback, a reading mirrored into two sources, the day-bucket, hour-
bucket and raw reads, a type with no ladder and a single-source account. The
route's unit test file reads its fixtures through a small adapter for the new
raw statements, and the dense-read guard drops the exception for the removed
findMany. The OpenAPI description states the rule.

Refs #1108
This commit is contained in:
BernhardBuckel committed 2026-10-03 20:18:50 +02:00
1 parent 02b6f06521
commit bb1acfab64
10 files changed
+566 -57

No files matched your search

@@ -9,6 +9,9 @@ vi.mock("@/lib/db", () => ({
// v1.28.25 — dense kinds (glucose / pulse) day-bucket in SQL for
// windows beyond the 90-day raw cap.
$queryRaw: vi.fn(),
// The series reads go through the ladder-winning source per day (see
// `seriesRowsFrom`), which is raw SQL with the user id as `$1`.
$queryRawUnsafe: vi.fn(),
},
}));
@@ -65,8 +68,47 @@ function req(query: string): NextRequest {
return new NextRequest(`http://localhost/api/measurements/series?${query}`);
}
/**
* The route reads its series through `$queryRawUnsafe` (the ladder-winning
* source per day). This file's fixtures are written as rows per type and as the
* two aggregate results, so the adapter routes each statement back to them:
* the day/hour bucket and the stats aggregate take the next `$queryRaw` result,
* and a raw read asks `findMany` for the type named in the SQL and returns the
* rows in the column names the statement selects. The SQL itself (which source
* wins, which rows count) is covered against a real database in
* `tests/integration/series-source-ladder.test.ts`.
*/
function adaptSeriesSql(sql: string): Promise<unknown> {
if (/GROUP BY local_day|STDDEV_POP/.test(sql)) {
return (prisma.$queryRaw as unknown as () => Promise<unknown>)();
}
const type = /'([A-Z0-9_]+)'::"measurement_type"/.exec(sql)?.[1];
const find = prisma.measurement.findMany as unknown as (
a: unknown,
) => Promise<
Array<{
id: string;
value: number;
measuredAt: Date;
valueMin?: number | null;
valueMax?: number | null;
}>
>;
return find({ where: { type } }).then((rows) =>
rows.map((r) => ({
id: r.id,
value: r.value,
measured_at: r.measuredAt,
value_min: r.valueMin ?? null,
value_max: r.valueMax ?? null,
})),
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.$queryRawUnsafe).mockImplementation(((sql: string) =>
adaptSeriesSql(sql)) as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
@@ -216,14 +258,14 @@ describe("GET /api/measurements/series", () => {
expect(body.data.points[1].valueMin).toBeNull();
expect(body.data.points[1].valueMax).toBeNull();
// The pulse read selects the spread columns.
const selectArg = vi.mocked(prisma.measurement.findMany).mock.calls[0][0];
expect(
(selectArg as { select: Record<string, boolean> }).select.valueMin,
).toBe(true);
expect(
(selectArg as { select: Record<string, boolean> }).select.valueMax,
).toBe(true);
// The pulse read selects the spread columns, and reads the pulse rows of
// this person's ladder-winning source (the user id is the first parameter).
const [sql, userId] = vi.mocked(prisma.$queryRawUnsafe).mock
.calls[0] as unknown as [string, string];
expect(sql).toContain('m."value_min"');
expect(sql).toContain('m."value_max"');
expect(sql).toContain(`'PULSE'::"measurement_type"`);
expect(userId).toBe("user-1");
});
it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => {
@@ -627,8 +669,12 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
expect(body.data.stats.count).toBe(43_200);
expect(body.data.stats.mean).toBe(72.1);
// The bucket read folds per hour, not per day.
const bucketSql = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
expect(bucketSql.slice(1)).toContain("hour");
const bucketCall = vi
.mocked(prisma.$queryRawUnsafe)
.mock.calls.find(([sql]) =>
/GROUP BY local_day/.test(sql as string),
) as unknown[];
expect(bucketCall.slice(1)).toContain("hour");
});
it("hour-buckets a CGM glucose window too dense to send raw, in the user's unit", async () => {
@@ -690,8 +736,12 @@ describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)",
max: 14.4,
stdDev: 2,
});
const bucketSql = vi.mocked(prisma.$queryRaw).mock.calls[0] as unknown[];
expect(bucketSql.slice(1)).toContain("hour");
const bucketCall = vi
.mocked(prisma.$queryRawUnsafe)
.mock.calls.find(([sql]) =>
/GROUP BY local_day/.test(sql as string),
) as unknown[];
expect(bucketCall.slice(1)).toContain("hour");
});
it("keeps fingerstick glucose at or under the row cap raw", async () => {
@@ -22,6 +22,7 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
$queryRaw: vi.fn(),
$queryRawUnsafe: vi.fn(),
},
}));
@@ -74,6 +75,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
+64 -38
View File
@@ -26,6 +26,7 @@ import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { VALUE_RANGES } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
@@ -399,49 +400,57 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
// per-sample / hourly pulse). Day-bucket in SQL instead of walking
// every raw row into JS: one aggregate pass in Postgres returns at
// most `days` rows regardless of sample density. Parameter-bound
// tagged-template `$queryRaw`, mirroring the rollup tier's
// `date_trunc(... ) GROUP BY` aggregate (measurement-rollups.ts).
const userTz = await resolveUserTimezone(user.id);
// most `days` rows regardless of sample density. Bound parameters
// mirror the rollup tier's `date_trunc(... ) GROUP BY` aggregate
// (measurement-rollups.ts), and the rows are the ladder-winning source per
// day, the same rule the rollup tier and sleep apply.
const [userTz, priorityJson] = await Promise.all([
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
]);
const type = KIND_TO_TYPE[kind];
const bucketRows = await prisma.$queryRaw<
const seriesRows = seriesRowsFrom(priorityJson, type, days);
const bucketRows = await prisma.$queryRawUnsafe<
Array<{
bucket_start: Date;
mean: number;
min_value: number;
max_value: number;
}>
>`
>(
`
WITH localized AS (
SELECT
m."value",
m."value_min",
m."value_max",
date_trunc(
${grain},
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${userTz}
$2,
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
) AS local_day
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."type" = ${type}::"measurement_type"
AND m."measured_at" >= ${since}
AND m."deleted_at" IS NULL
FROM ${seriesRows}
WHERE m."measured_at" >= $4
)
SELECT
local_day AT TIME ZONE ${userTz} AS bucket_start,
local_day AT TIME ZONE $3 AS bucket_start,
AVG("value")::double precision AS mean,
MIN(COALESCE("value_min", "value"))::double precision AS min_value,
MAX(COALESCE("value_max", "value"))::double precision AS max_value
FROM localized
GROUP BY local_day
ORDER BY bucket_start ASC
`;
`,
user.id,
grain,
userTz,
since,
);
// Stats stay aggregated over the RAW rows (not the day buckets) so the
// strip's mean/min/max/stdDev/count are the same figures the raw path
// computed: `AVG`/`MIN`/`MAX` over `value`, `STDDEV_POP` matching the
// JS population-variance helper, exact row count. Aggregate-only —
// no row transfer.
const [rawAgg] = await prisma.$queryRaw<
const [rawAgg] = await prisma.$queryRawUnsafe<
Array<{
n: number;
mean: number | null;
@@ -449,19 +458,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
max: number | null;
sd: number | null;
}>
>`
>(
`
SELECT
COUNT(*)::int AS n,
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
STDDEV_POP(m."value")::double precision AS sd
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."type" = ${type}::"measurement_type"
AND m."measured_at" >= ${since}
AND m."deleted_at" IS NULL
`;
FROM ${seriesRows}
WHERE m."measured_at" >= $2
`,
user.id,
since,
);
const round2 = (v: number) => Math.round(v * 100) / 100;
const dayId = (d: Date) =>
grain === "day"
@@ -538,21 +548,37 @@ export const GET = apiHandler(async (request: NextRequest) => {
// hourly average. Only PULSE rows ever carry a non-null spread (the
// hourly HR bucket); every other kind selects the bare value shape.
const includeSpread = kind === "pulse";
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type,
measuredAt: { gte: since },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: {
id: true,
value: true,
measuredAt: true,
...(includeSpread ? { valueMin: true, valueMax: true } : {}),
},
});
// The ladder-winning source per day (see `seriesRowsFrom`), so a second
// provider neither blends into nor duplicates the series. Bounded as before:
// every dense kind reaches this read only inside 90 days and under 10 000
// rows (counted first), and the sparse kinds are a few readings a day.
const sourcePriority = await loadUserSourcePriority(user.id);
const rawRows = await prisma.$queryRawUnsafe<
Array<{
id: string;
value: number;
measured_at: Date;
value_min: number | null;
value_max: number | null;
}>
>(
`
SELECT m."id", m."value", m."measured_at", m."value_min", m."value_max"
FROM ${seriesRowsFrom(sourcePriority, type, days)}
WHERE m."measured_at" >= $2
ORDER BY m."measured_at" ASC, m."id" ASC
`,
user.id,
since,
);
const rows = rawRows.map((r) => ({
id: r.id,
value: r.value,
measuredAt: r.measured_at,
...(includeSpread
? { valueMin: r.value_min, valueMax: r.value_max }
: {}),
}));
if (kind === "glucose") {
// v1.16.16 — glucose is stored canonical mg/dL; convert each point to
// the user's display unit AT SERIALIZATION so the wire DTO is unit-