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

+17 -3
View File
@@ -123,11 +123,13 @@ function assertInterval(value: string): string {
* The user id is bound as `$1`. `rankUnqualified` must be a CASE built with
* unqualified `"type"`/`"source"` columns. `sinceInterval` (e.g. `"90 days"`)
* is whitelisted and, when given, scopes both the inner pick and the outer
* filter to a trailing window.
* filter to a trailing window. `onlyType`, when given, scopes both halves to
* that one metric.
*/
export function canonicalMeasurementsCte(
rankUnqualified: string,
sinceInterval?: string,
onlyType?: MeasurementType,
): string {
const sinceInner = sinceInterval
? `AND "measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'`
@@ -135,6 +137,15 @@ export function canonicalMeasurementsCte(
const sinceOuter = sinceInterval
? `AND mm."measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'`
: "";
// A single-type reader (a series for one metric) scopes both halves to that
// type, so the pick scans one metric's rows instead of every row the user
// has in the window.
const typeInner = onlyType
? `AND "type" = '${assertEnumLiteral(onlyType)}'::"measurement_type"`
: "";
const typeOuter = onlyType
? `AND mm."type" = '${assertEnumLiteral(onlyType)}'::"measurement_type"`
: "";
return `
SELECT mm.*
FROM measurements mm
@@ -147,6 +158,7 @@ export function canonicalMeasurementsCte(
WHERE "user_id" = $1
AND "deleted_at" IS NULL
${sinceInner}
${typeInner}
ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"::text
) c
ON c.t = mm."type"
@@ -154,7 +166,8 @@ export function canonicalMeasurementsCte(
AND c.canon = mm."source"
WHERE mm."user_id" = $1
AND mm."deleted_at" IS NULL
${sinceOuter}`;
${sinceOuter}
${typeOuter}`;
}
/**
@@ -173,7 +186,8 @@ export function canonicalMeasurementsCte(
export function canonicalMeasurementsFrom(
rankUnqualified: string,
sinceInterval?: string,
onlyType?: MeasurementType,
): string {
return `(${canonicalMeasurementsCte(rankUnqualified, sinceInterval)}
return `(${canonicalMeasurementsCte(rankUnqualified, sinceInterval, onlyType)}
) m`;
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { canonicalMeasurementsCte } from "@/lib/analytics/source-rank-sql";
import { seriesRowsFrom } from "../series-canonical";
describe("seriesRowsFrom", () => {
it("collapses a type that has a ladder to the ladder's source per day", () => {
const sql = seriesRowsFrom(null, "PULSE", 30);
expect(sql).toContain("DISTINCT ON");
// Scoped to the one metric, in both halves of the helper.
expect(sql).toContain(`"type" = 'PULSE'::"measurement_type"`);
expect(sql).toContain(`mm."type" = 'PULSE'::"measurement_type"`);
expect(sql.trimEnd().endsWith(") m")).toBe(true);
});
it("reads one day wider than the request, so the edge day is picked from all its readings", () => {
expect(seriesRowsFrom(null, "WEIGHT", 30)).toContain("INTERVAL '31 days'");
expect(seriesRowsFrom(null, "WEIGHT", 3650)).toContain(
"INTERVAL '3651 days'",
);
});
it("applies the person's ladder, not only the default one", () => {
const first = seriesRowsFrom(
{ pulse: ["FITBIT", "APPLE_HEALTH"] },
"PULSE",
7,
);
const second = seriesRowsFrom(
{ pulse: ["APPLE_HEALTH", "FITBIT"] },
"PULSE",
7,
);
expect(first).not.toEqual(second);
expect(first).toMatch(/WHEN 'FITBIT' THEN 0 WHEN 'APPLE_HEALTH' THEN 1/);
expect(second).toMatch(/WHEN 'APPLE_HEALTH' THEN 0 WHEN 'FITBIT' THEN 1/);
});
it.each(["BLOOD_GLUCOSE", "TOTAL_BODY_WATER", "BONE_MASS"] as const)(
"keeps every source for %s, which has no ladder",
(type) => {
const sql = seriesRowsFrom(null, type, 30);
expect(sql).not.toContain("DISTINCT ON");
expect(sql).toContain(`mm."type" = '${type}'::"measurement_type"`);
expect(sql).toContain(`mm."user_id" = $1`);
expect(sql).toContain(`mm."deleted_at" IS NULL`);
},
);
it.each([0, -1, 1.5, 3651, Number.NaN])("refuses days = %s", (days) => {
expect(() => seriesRowsFrom(null, "PULSE", days)).toThrow(/days/);
});
});
describe("canonicalMeasurementsCte, one-metric scope", () => {
const rank = `CASE "source" WHEN 'A' THEN 0 ELSE 90 END`;
it("is unchanged when no type is given", () => {
const sql = canonicalMeasurementsCte(rank, "90 days");
expect(sql).not.toContain("measurement_type");
});
it("scopes the pick and the join to the type when one is given", () => {
const sql = canonicalMeasurementsCte(rank, "90 days", "PULSE");
expect(sql.match(/'PULSE'::"measurement_type"/g)).toHaveLength(2);
});
it("refuses anything that is not a closed-enum literal", () => {
expect(() =>
canonicalMeasurementsCte(rank, undefined, "PULSE'; DROP" as never),
).toThrow(/unsafe enum literal/);
});
});
+56
View File
@@ -0,0 +1,56 @@
/**
* The rows a metric series reads, restricted to the source the user's priority
* ladder picks for each day.
*
* The rollup tier and sleep already resolve every (type, day) to the
* ladder-winning source 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 and the configured ladder had no
* effect on those charts. This is the one place that applies the same rule to
* them, through the same SQL helper the other live readers use.
*
* Only a type that HAS a ladder is collapsed (`RANKED_TYPES`). For a type
* without one (glucose, body water, bone mass) the helper would break the tie
* by source name, which silently hides one of two real sources, so those keep
* every source exactly as before.
*
* Returns a FROM-clause subquery aliased `m`; the user id is bound as `$1`.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import {
RANKED_TYPES,
buildSourceRankCase,
canonicalMeasurementsFrom,
} from "@/lib/analytics/source-rank-sql";
const ENUM_RE = /^[A-Z0-9_]+$/;
export function seriesRowsFrom(
priorityJson: unknown,
type: MeasurementType,
days: number,
): string {
if (!ENUM_RE.test(type)) {
throw new Error(`unsafe enum literal for SQL splice: ${type}`);
}
if (!Number.isInteger(days) || days < 1 || days > 3650) {
throw new Error(`days out of range for SQL splice: ${days}`);
}
if (!RANKED_TYPES.includes(type)) {
return `(
SELECT mm.*
FROM measurements mm
WHERE mm."user_id" = $1
AND mm."type" = '${type}'::"measurement_type"
AND mm."deleted_at" IS NULL
) m`;
}
// One day wider than the request: the caller trims to its exact `since`, so
// the edge day's source is picked from all of its readings, not from the
// slice the interval happens to include.
return canonicalMeasurementsFrom(
buildSourceRankCase(priorityJson, '"type"', '"source"'),
`${days + 1} days`,
type,
);
}
+1 -1
View File
@@ -840,7 +840,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Per-kind time series (iOS chart source)",
description:
"Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`) within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to 365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Auth via cookie or Bearer.",
"Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`) within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to 365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Where a metric has a source-priority ladder (pulse, weight, SpO2, HRV, ...), each day shows the readings of the source the person's ladder picks for that day, so a second provider never blends into or duplicates a day; a metric without a ladder (glucose, body water, bone mass) keeps every source. Auth via cookie or Bearer.",
requestParams: {
query: seriesQuerySchema,
},