mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge branch 'fix/analytics-live-regression' into fix/review-meds-docs
This commit is contained in:
commit
8aab59cdc1
16 files changed
+1671
-267
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 349,
|
||||
"total": 113048,
|
||||
"count": 348,
|
||||
"total": 112838,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
@@ -275,7 +275,6 @@
|
||||
"lib/doctor-report-pdf/measurements-charts-section.ts :: buildMeasurementsChartsSection": 384,
|
||||
"lib/doctor-report-pdf/medication-mood-wellness-section.ts :: buildMedicationMoodWellnessSection": 509,
|
||||
"lib/doctor-report/collect.ts :: collectDoctorReportData": 667,
|
||||
"lib/doctor-report/dense-buckets.ts :: loadDenseMeasurementBuckets": 210,
|
||||
"lib/documents/fenced-chat.ts :: streamFencedReply": 309,
|
||||
"lib/documents/ingest.ts :: ingestDocument": 272,
|
||||
"lib/export/backup-json-scan.ts :: scanBackupJson": 225,
|
||||
|
||||
@@ -316,7 +316,7 @@ describe("computeBpInTargetFastPath", () => {
|
||||
// a raw `findMany` (double-counting) fails here.
|
||||
for (const call of QUERY_RAW.mock.calls) {
|
||||
const sql = String(call[0]);
|
||||
expect(sql).toContain("DISTINCT ON");
|
||||
expect(sql).toContain("canonical_source");
|
||||
expect(sql).toContain("date_trunc('day'");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
* self-join. These tests pin:
|
||||
* 1. the alias-wrapped `canonicalMeasurementsFrom` is exactly the CTE body
|
||||
* wrapped in `( … ) m` — so the single-reference call site is unchanged;
|
||||
* 2. the canonical body carries exactly ONE DISTINCT-ON pick — so a fold
|
||||
* that references the CTE twice runs the expensive pick once;
|
||||
* 2. the canonical body reads `measurements` once and picks with one window
|
||||
* function, with no join for the planner to run as a nested loop — so a
|
||||
* fold that references the CTE twice runs the pick once, and a table
|
||||
* without statistics cannot turn it quadratic;
|
||||
* 3. the interval whitelist still rejects an unsafe splice;
|
||||
* 4. the window counts from an instant the caller took, never from the
|
||||
* database clock.
|
||||
@@ -34,21 +36,22 @@ describe("canonicalMeasurementsCte / canonicalMeasurementsFrom", () => {
|
||||
expect(from).toBe(`(${cte}\n ) m`);
|
||||
});
|
||||
|
||||
it("runs exactly one DISTINCT-ON canonical pick in the body", () => {
|
||||
it("picks with one window over one scan, with no join", () => {
|
||||
const cte = canonicalMeasurementsCte(RANK, NINETY_DAYS);
|
||||
const matches = cte.match(/DISTINCT ON/g) ?? [];
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(cte.match(/FROM measurements\b/g) ?? []).toHaveLength(1);
|
||||
expect(cte.match(/FIRST_VALUE\(/g) ?? []).toHaveLength(1);
|
||||
expect(cte).not.toMatch(/\bJOIN\b|DISTINCT ON/);
|
||||
});
|
||||
|
||||
it("binds the user id as $1 and scopes both the inner and outer windows", () => {
|
||||
it("binds the user id as $1 and scopes the scan to the window", () => {
|
||||
const cte = canonicalMeasurementsCte(RANK, NINETY_DAYS);
|
||||
// user id is always a bound parameter, never spliced.
|
||||
expect(cte).toContain('"user_id" = $1');
|
||||
// The trailing-window bound appears on both the inner pick and the outer
|
||||
// filter so the folded references stay scoped identically.
|
||||
// The trailing-window bound scopes the one scan, so the pick and the rows
|
||||
// it keeps are cut by the same window.
|
||||
const intervalMatches =
|
||||
cte.match(/>= \$2::timestamp - INTERVAL '90 days'/g) ?? [];
|
||||
expect(intervalMatches).toHaveLength(2);
|
||||
expect(intervalMatches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("never cuts its window on the database clock", () => {
|
||||
|
||||
@@ -121,35 +121,45 @@ export interface CanonicalWindow {
|
||||
/**
|
||||
* Build the canonical-source SELECT body — the raw `measurements` rows
|
||||
* restricted to the ladder-winning source per (type, day) — WITHOUT the
|
||||
* outer `( … ) m` alias wrapper. The inner `DISTINCT ON` picks the
|
||||
* canonical source for each day and the join keeps only that source's
|
||||
* readings.
|
||||
* outer `( … ) m` alias wrapper. One scan reads the window's rows; a window
|
||||
* function names each (type, day)'s canonical source on every row, and the
|
||||
* rows of any other source are dropped.
|
||||
*
|
||||
* Use this when the same canonical set must be referenced more than once in
|
||||
* one statement (e.g. a `window_stats` CTE plus the outer aggregate): bind it
|
||||
* once via `WITH cm AS (${canonicalMeasurementsCte(...)})` and select
|
||||
* `FROM cm m` everywhere, so the expensive DISTINCT-ON self-join runs once
|
||||
* rather than per reference. For the single-reference case use
|
||||
* `canonicalMeasurementsFrom`, which wraps this body in the alias.
|
||||
* `FROM cm m` everywhere, so the pick runs once rather than per reference.
|
||||
* For the single-reference case use `canonicalMeasurementsFrom`, which wraps
|
||||
* this body in the alias.
|
||||
*
|
||||
* The rows carry every `measurements` column plus `canonical_source`, the
|
||||
* picked source (equal to `source` on every row that survives). Readers
|
||||
* select the columns they need, so the extra one never reaches them.
|
||||
*
|
||||
* The user id is bound as `$1`. `rankUnqualified` must be a CASE built with
|
||||
* unqualified `"type"`/`"source"` columns. `window`, when given, scopes both
|
||||
* the inner pick and the outer filter to the readings from `from` on, or from
|
||||
* `interval` (e.g. `"90 days"`, whitelisted) before it. `from` is an instant
|
||||
* the caller took (`boundInstantSql` or `instantLiteralSql`, see
|
||||
* `request-clock-sql.ts`), the request's `now` for a trailing window, so the
|
||||
* cut runs on the caller's clock and never on the database's. `onlyType`, when given, scopes both
|
||||
* halves to that one metric.
|
||||
* unqualified `"type"`/`"source"` columns. `window`, when given, scopes the
|
||||
* scan to the readings from `from` on, or from `interval` (e.g. `"90 days"`,
|
||||
* whitelisted) before it. `from` is an instant the caller took
|
||||
* (`boundInstantSql` or `instantLiteralSql`, see `request-clock-sql.ts`), the
|
||||
* request's `now` for a trailing window, so the cut runs on the caller's
|
||||
* clock and never on the database's. `onlyType`, when given, scopes the scan
|
||||
* to that one metric.
|
||||
*
|
||||
* The pick groups before it sorts. Its order (rank, then source name) depends
|
||||
* only on a row's `(type, source)`, so every reading of one source on one day
|
||||
* sorts alike, and the distinct `(type, day, source)` triples carry the whole
|
||||
* decision. A hash aggregate reduces the window's rows to those triples (a
|
||||
* few thousand) and `DISTINCT ON` sorts only them; sorting every raw row by
|
||||
* a CASE expression no index can supply spilled to disk on a large account.
|
||||
* The picked source per day is the same by construction;
|
||||
* `tests/integration/canonical-source-pick-parity.test.ts` holds the old
|
||||
* statement as its reference.
|
||||
* The pick is the first source of the day in ladder order (rank, then source
|
||||
* name), the same order the `DISTINCT ON` pick it replaced used, over the
|
||||
* same rows, so every (type, day) keeps the same source;
|
||||
* `tests/integration/canonical-source-pick-parity.test.ts` holds the
|
||||
* `DISTINCT ON` statement as its reference.
|
||||
*
|
||||
* Why no join: the earlier shape grouped the window to `(type, day, source)`,
|
||||
* picked per day with `DISTINCT ON`, and joined the picks back to the rows.
|
||||
* Whether that join ran as a hash join or as a nested loop that rescanned the
|
||||
* pick for every row was the planner's call, made from table statistics. On
|
||||
* a table without them (a new install, or an import still under the
|
||||
* autoanalyze threshold) it estimated one row and chose the nested loop: on a
|
||||
* 6,000-row fixture the all-time read took seconds, on a ten-year account it
|
||||
* ran past the statement timeout. A window over one scan has no such choice
|
||||
* to get wrong; `canonical-source-pick-unanalysed.test.ts` holds that.
|
||||
*/
|
||||
export function canonicalMeasurementsCte(
|
||||
rankUnqualified: string,
|
||||
@@ -161,52 +171,37 @@ export function canonicalMeasurementsCte(
|
||||
: window.interval === undefined
|
||||
? assertInstantSql(window.from)
|
||||
: `${assertInstantSql(window.from)} - INTERVAL '${assertInterval(window.interval)}'`;
|
||||
const sinceInner = bound ? `AND "measured_at" >= ${bound}` : "";
|
||||
const sinceOuter = bound ? `AND mm."measured_at" >= ${bound}` : "";
|
||||
// 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
|
||||
const since = bound ? `AND mm."measured_at" >= ${bound}` : "";
|
||||
// A single-type reader (a series for one metric) scopes the scan to that
|
||||
// type, so the pick reads 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
|
||||
const typeOnly = onlyType
|
||||
? `AND mm."type" = '${assertEnumLiteral(onlyType)}'::"measurement_type"`
|
||||
: "";
|
||||
return `
|
||||
SELECT mm.*
|
||||
FROM measurements mm
|
||||
JOIN (
|
||||
SELECT DISTINCT ON ("type", d)
|
||||
"type" AS t,
|
||||
d,
|
||||
"source" AS canon
|
||||
FROM (
|
||||
SELECT "type", date_trunc('day', "measured_at") AS d, "source"
|
||||
FROM measurements
|
||||
WHERE "user_id" = $1
|
||||
AND "deleted_at" IS NULL
|
||||
${sinceInner}
|
||||
${typeInner}
|
||||
GROUP BY "type", date_trunc('day', "measured_at"), "source"
|
||||
) g
|
||||
ORDER BY "type", d, (${rankUnqualified}), "source"::text
|
||||
) c
|
||||
ON c.t = mm."type"
|
||||
AND c.d = date_trunc('day', mm."measured_at")
|
||||
AND c.canon = mm."source"
|
||||
WHERE mm."user_id" = $1
|
||||
AND mm."deleted_at" IS NULL
|
||||
${sinceOuter}
|
||||
${typeOuter}`;
|
||||
SELECT w.*
|
||||
FROM (
|
||||
SELECT mm.*,
|
||||
FIRST_VALUE(mm."source") OVER (
|
||||
PARTITION BY mm."type", date_trunc('day', mm."measured_at")
|
||||
ORDER BY (${rankUnqualified}), mm."source"::text
|
||||
) AS canonical_source
|
||||
FROM measurements mm
|
||||
WHERE mm."user_id" = $1
|
||||
AND mm."deleted_at" IS NULL
|
||||
${since}
|
||||
${typeOnly}
|
||||
) w
|
||||
WHERE w."source" = w.canonical_source`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a FROM-clause subquery (aliased `m`) that restricts raw `measurements`
|
||||
* to the canonical-source rows per (type, day): the inner `DISTINCT ON` picks
|
||||
* the ladder-winning source for each day, and the join keeps only that source's
|
||||
* readings. Use it in place of `FROM measurements m` so a live aggregate over
|
||||
* an overlapping vital never blends two devices — the same collapse the rollup
|
||||
* readers apply, kept in lockstep for live/rollup parity.
|
||||
* to the canonical-source rows per (type, day): the ladder-winning source for
|
||||
* each day, and only that source's readings. Use it in place of
|
||||
* `FROM measurements m` so a live aggregate over an overlapping vital never
|
||||
* blends two devices — the same collapse the rollup readers apply, kept in
|
||||
* lockstep for live/rollup parity.
|
||||
*
|
||||
* The user id is bound as `$1`; `rankUnqualified`, `window` and `onlyType` are
|
||||
* those of `canonicalMeasurementsCte`.
|
||||
|
||||
@@ -573,10 +573,10 @@ async function computeFromRollups(
|
||||
|
||||
const [narrows, latests, dayBuckets, accumulatorRows, preFoldRemainder] =
|
||||
await Promise.all([
|
||||
// The FROM is restricted to the canonical-source rows per (type, day): the
|
||||
// inner DISTINCT ON picks the ladder-winning source for each day, and the
|
||||
// join keeps only that source's readings so the 90-day AVG / median / slope
|
||||
// never blend two devices that both reported the same vital.
|
||||
// The FROM is restricted to the canonical-source rows per (type, day):
|
||||
// the ladder-winning source for each day, and only that source's
|
||||
// readings, so the 90-day AVG / median / slope never blend two devices
|
||||
// that both reported the same vital.
|
||||
// The window means weigh each day once for an hourly-mean type
|
||||
// (`windowedMean`); the median stays over every reading.
|
||||
prisma.$queryRawUnsafe<NarrowAggregateRow[]>(
|
||||
|
||||
@@ -81,6 +81,13 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
const hourlyMeanType = Prisma.raw(isHourlyMeanTypeSql('"type"'));
|
||||
// Each hour's weight under the active pulse day-mean method (code-built).
|
||||
const hourWeight = Prisma.raw(hourWeightSql("n"));
|
||||
// One grouping over one scan, no join. The latest reading and the hour
|
||||
// means used to be groupings of their own, joined back to the buckets; on
|
||||
// a measurements table without planner statistics (a new install, an
|
||||
// import under the autoanalyze threshold) those joins ran as nested loops
|
||||
// that re-ran a grouping for every bucket, and a long report window ran
|
||||
// into the statement timeout. `tests/integration/per-source-fold-parity.test.ts`
|
||||
// holds the joined statement as the reference.
|
||||
return prisma.$queryRaw<DenseMeasurementBucket[]>`
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
@@ -106,6 +113,9 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${typeList})
|
||||
),
|
||||
-- Every row carries its bucket's latest reading and its local hour's
|
||||
-- reading count and mean, with one row per hour marked, so the one
|
||||
-- grouping below folds them all.
|
||||
scored AS (
|
||||
SELECT
|
||||
localized.*,
|
||||
@@ -113,72 +123,18 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
WHEN "value" > 0
|
||||
THEN 1.509 * (power(ln("value"), 1.084) - 5.381)
|
||||
ELSE NULL
|
||||
END AS risk_transform
|
||||
END AS risk_transform,
|
||||
FIRST_VALUE("value") OVER (
|
||||
PARTITION BY local_day, "type", "source", "device_type", "glucose_context"
|
||||
ORDER BY "measured_at" DESC, "id" DESC
|
||||
) AS latest_value,
|
||||
COUNT(*) OVER hour AS n,
|
||||
AVG("value") OVER hour AS hour_mean,
|
||||
ROW_NUMBER() OVER hour = 1 AS hour_first
|
||||
FROM localized
|
||||
),
|
||||
latest_rows AS (
|
||||
SELECT DISTINCT ON (
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
WINDOW hour AS (
|
||||
PARTITION BY local_day, local_hour, "type", "source", "device_type", "glucose_context"
|
||||
)
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
"measured_at" AS latest_at,
|
||||
"value" AS latest_value
|
||||
FROM localized
|
||||
ORDER BY
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
"measured_at" DESC,
|
||||
"id" DESC
|
||||
),
|
||||
-- The hourly-mean day (pulse): each local hour's mean and reading
|
||||
-- count, then the weighted sum of the means and the sum of the hour
|
||||
-- weights per bucket. Empty for every other type.
|
||||
hour_means AS (
|
||||
SELECT
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
AVG("value") AS hour_mean,
|
||||
COUNT(*) AS n
|
||||
FROM localized
|
||||
WHERE ${hourlyMeanType}
|
||||
GROUP BY
|
||||
local_day,
|
||||
local_hour,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
),
|
||||
hour_agg AS (
|
||||
SELECT
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
SUM(hour_mean * ${hourWeight})::double precision AS hour_weighted_sum,
|
||||
SUM(${hourWeight})::double precision AS hour_weight
|
||||
FROM hour_means
|
||||
GROUP BY
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
),
|
||||
grouped AS (
|
||||
SELECT
|
||||
@@ -188,6 +144,14 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
COUNT(*)::int AS sample_count,
|
||||
MAX("measured_at") AS latest_at,
|
||||
MAX(latest_value)::double precision AS latest_value,
|
||||
SUM(hour_mean * ${hourWeight})
|
||||
FILTER (WHERE hour_first AND ${hourlyMeanType})::double precision
|
||||
AS hour_weighted_sum,
|
||||
SUM(${hourWeight})
|
||||
FILTER (WHERE hour_first AND ${hourlyMeanType})::double precision
|
||||
AS hour_weight,
|
||||
SUM("value")::double precision AS sum_value,
|
||||
MIN("value")::double precision AS min_value,
|
||||
MAX("value")::double precision AS max_value,
|
||||
@@ -242,8 +206,8 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
grouped.sum_value AS "sumValue",
|
||||
grouped.min_value AS "minValue",
|
||||
grouped.max_value AS "maxValue",
|
||||
latest_rows.latest_at AS "latestAt",
|
||||
latest_rows.latest_value::double precision AS "latestValue",
|
||||
grouped.latest_at AS "latestAt",
|
||||
grouped.latest_value AS "latestValue",
|
||||
grouped.clinical_count AS "clinicalCount",
|
||||
grouped.clinical_sum AS "clinicalSum",
|
||||
grouped.clinical_sum_squares AS "clinicalSumSquares",
|
||||
@@ -256,21 +220,9 @@ export async function loadDenseMeasurementBuckets(params: {
|
||||
grouped.clinical_tar2_count AS "clinicalTar2Count",
|
||||
grouped.clinical_low_risk_sum AS "clinicalLowRiskSum",
|
||||
grouped.clinical_high_risk_sum AS "clinicalHighRiskSum",
|
||||
hour_agg.hour_weighted_sum AS "hourWeightedSum",
|
||||
hour_agg.hour_weight AS "hourWeight"
|
||||
grouped.hour_weighted_sum AS "hourWeightedSum",
|
||||
grouped.hour_weight AS "hourWeight"
|
||||
FROM grouped
|
||||
INNER JOIN latest_rows
|
||||
ON latest_rows.local_day = grouped.local_day
|
||||
AND latest_rows."type" = grouped."type"
|
||||
AND latest_rows."source" = grouped."source"
|
||||
AND latest_rows."device_type" IS NOT DISTINCT FROM grouped."device_type"
|
||||
AND latest_rows."glucose_context" IS NOT DISTINCT FROM grouped."glucose_context"
|
||||
LEFT JOIN hour_agg
|
||||
ON hour_agg.local_day = grouped.local_day
|
||||
AND hour_agg."type" = grouped."type"
|
||||
AND hour_agg."source" = grouped."source"
|
||||
AND hour_agg."device_type" IS NOT DISTINCT FROM grouped."device_type"
|
||||
AND hour_agg."glucose_context" IS NOT DISTINCT FROM grouped."glucose_context"
|
||||
ORDER BY "bucketStart" ASC, grouped."type" ASC
|
||||
|
||||
`;
|
||||
|
||||
@@ -403,10 +403,10 @@ async function buildFromRollups(
|
||||
timeSubquery(timings, "narrow", () =>
|
||||
prisma.$queryRawUnsafe<NarrowAggregateRow[]>(
|
||||
`
|
||||
-- v1.18.11 perf#3a — the canonical-source self-join is the dominant
|
||||
-- v1.18.11 perf#3a — the canonical-source pick is the dominant
|
||||
-- cost; bind it ONCE as a CTE and reference it from both window_stats
|
||||
-- and the outer aggregate so the DISTINCT-ON pick runs a single time
|
||||
-- instead of twice per rebuild. Output is identical.
|
||||
-- and the outer aggregate so the pick runs a single time instead of
|
||||
-- twice per rebuild. Output is identical.
|
||||
WITH cm AS (${canonicalMeasurementsCte(rankUnqualified, NINETY_DAYS)}
|
||||
),
|
||||
-- The z-score's mean and spread stay over every reading: they measure
|
||||
@@ -626,9 +626,9 @@ async function buildFromLiveAggregate(
|
||||
timeSubquery(timings, "heavy", () =>
|
||||
prisma.$queryRawUnsafe<HeavyAggregateRow[]>(
|
||||
`
|
||||
-- v1.18.11 perf#3a — bind the canonical-source self-join once (see the
|
||||
-- v1.18.11 perf#3a — bind the canonical-source pick once (see the
|
||||
-- warm narrow query). window_stats + the outer aggregate both read cm,
|
||||
-- so the DISTINCT-ON pick runs once. Output is identical.
|
||||
-- so the pick runs once. Output is identical.
|
||||
WITH cm AS (${canonicalMeasurementsCte(rankUnqualified, NINETY_DAYS)}
|
||||
),
|
||||
-- The z-score's mean and spread stay over every reading: they measure
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import {
|
||||
dayWeightedRowsSql,
|
||||
foldMeanSql,
|
||||
isHourlyMeanTypeSql,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import { dayWeightedRowsSql, foldMeanSql } from "@/lib/measurements/day-mean";
|
||||
import {
|
||||
loadUserSourcePriority,
|
||||
utcBucketEnd,
|
||||
@@ -207,9 +203,18 @@ export async function readAllTimeExtremes(
|
||||
ORDER BY r."type", r."bucket_start", (${rank("r")}), r."source"::text
|
||||
) tier`;
|
||||
// A day of pulse is the mean of its hours' means and the full history the
|
||||
// mean of its days (`day-mean.ts`). `weighted` carries, per source and day,
|
||||
// the day-weighted sum and the day's weight (one) for those types only;
|
||||
// every other type keeps the reading-weighted mean below, unchanged.
|
||||
// mean of its days (`day-mean.ts`). `per_source` carries, per source and
|
||||
// day, the day-weighted sum and the day's weight (one) beside the reading
|
||||
// count and sum; the mean below reads the weighted pair for those types
|
||||
// only, and every other type keeps the reading-weighted mean, unchanged.
|
||||
//
|
||||
// One fold of one scan, no join. The weighted pair used to be a second
|
||||
// grouping of the same rows, joined back to the per-source days before the
|
||||
// pick; on a measurements table without planner statistics (a new install,
|
||||
// an import under the autoanalyze threshold) that join ran as a nested
|
||||
// loop that re-ran the weighting for every source day, and ten years of
|
||||
// readings took seconds. `tests/integration/per-source-fold-parity.test.ts`
|
||||
// holds the joined statement as the reference.
|
||||
const mean = Prisma.raw(
|
||||
foldMeanSql({
|
||||
typeColumn: 'c."type"',
|
||||
@@ -223,30 +228,18 @@ export async function readAllTimeExtremes(
|
||||
Array<{ type: string; mean: number; min: number; max: number }>
|
||||
>`
|
||||
WITH live AS (${Prisma.join(rawArms, " UNION ALL ")}),
|
||||
hourly_src AS (
|
||||
SELECT * FROM live l
|
||||
WHERE ${Prisma.raw(isHourlyMeanTypeSql('l."type"'))}
|
||||
),
|
||||
weighted AS (
|
||||
SELECT
|
||||
w."type",
|
||||
w."source",
|
||||
date_trunc('day', w."measured_at") AS day,
|
||||
SUM(w."value" * w.day_weight)::double precision AS wsum,
|
||||
SUM(w.day_weight)::double precision AS wdays
|
||||
FROM ${dayWeightedRowsSql("hourly_src", null, { bySource: true })} w
|
||||
GROUP BY w."type", w."source", 3
|
||||
),
|
||||
per_source AS (
|
||||
SELECT
|
||||
l."type",
|
||||
l."source",
|
||||
date_trunc('day', l."measured_at") AS day,
|
||||
COUNT(*)::int AS cnt,
|
||||
SUM(l."value")::double precision AS total,
|
||||
MIN(l."value")::double precision AS min_value,
|
||||
MAX(l."value")::double precision AS max_value
|
||||
FROM live l
|
||||
date_trunc('day', l."measured_at") AS day,
|
||||
COUNT(*)::int AS cnt,
|
||||
SUM(l."value")::double precision AS total,
|
||||
MIN(l."value")::double precision AS min_value,
|
||||
MAX(l."value")::double precision AS max_value,
|
||||
SUM(l."value" * l.day_weight)::double precision AS wsum,
|
||||
SUM(l.day_weight)::double precision AS wdays
|
||||
FROM ${dayWeightedRowsSql("live", null, { bySource: true })} l
|
||||
GROUP BY l."type", l."source", 3
|
||||
)
|
||||
SELECT
|
||||
@@ -257,10 +250,8 @@ export async function readAllTimeExtremes(
|
||||
FROM (
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value, w.wsum, w.wdays
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value, p.wsum, p.wdays
|
||||
FROM per_source p
|
||||
LEFT JOIN weighted w
|
||||
ON w."type" = p."type" AND w."source" = p."source" AND w.day = p.day
|
||||
ORDER BY p."type", p.day, (${rank("p")}), p."source"::text
|
||||
) raw
|
||||
${tierCanon}
|
||||
|
||||
@@ -10,9 +10,8 @@ const SINCE = new Date("2026-03-01T06:30:00.000Z");
|
||||
describe("seriesRowsFrom", () => {
|
||||
it("collapses a type that has a ladder to the ladder's source per day", () => {
|
||||
const sql = seriesRowsFrom(null, "PULSE", SINCE);
|
||||
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("canonical_source");
|
||||
// Scoped to the one metric in the helper's one scan.
|
||||
expect(sql).toContain(`mm."type" = 'PULSE'::"measurement_type"`);
|
||||
expect(sql.trimEnd().endsWith(") m")).toBe(true);
|
||||
});
|
||||
@@ -21,7 +20,7 @@ describe("seriesRowsFrom", () => {
|
||||
const sql = seriesRowsFrom(null, "WEIGHT", SINCE);
|
||||
expect(
|
||||
sql.match(/>= '2026-03-01 06:30:00.000'::timestamp - INTERVAL '1 day'/g),
|
||||
).toHaveLength(2);
|
||||
).toHaveLength(1);
|
||||
// Counted from the caller's `since`, never from the database clock.
|
||||
expect(sql).not.toMatch(/NOW\(\)/i);
|
||||
});
|
||||
@@ -46,7 +45,7 @@ describe("seriesRowsFrom", () => {
|
||||
"keeps every source for %s, which has no ladder",
|
||||
(type) => {
|
||||
const sql = seriesRowsFrom(null, type, SINCE);
|
||||
expect(sql).not.toContain("DISTINCT ON");
|
||||
expect(sql).not.toContain("canonical_source");
|
||||
expect(sql).toContain(`mm."type" = '${type}'::"measurement_type"`);
|
||||
expect(sql).toContain(`mm."user_id" = $1`);
|
||||
expect(sql).toContain(`mm."deleted_at" IS NULL`);
|
||||
@@ -69,9 +68,9 @@ describe("canonicalMeasurementsCte, one-metric scope", () => {
|
||||
expect(sql).not.toContain("measurement_type");
|
||||
});
|
||||
|
||||
it("scopes the pick and the join to the type when one is given", () => {
|
||||
it("scopes the scan to the type when one is given", () => {
|
||||
const sql = canonicalMeasurementsCte(rank, NINETY_DAYS, "PULSE");
|
||||
expect(sql.match(/'PULSE'::"measurement_type"/g)).toHaveLength(2);
|
||||
expect(sql.match(/'PULSE'::"measurement_type"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuses anything that is not a closed-enum literal", () => {
|
||||
|
||||
@@ -217,13 +217,22 @@ export async function readLiveBuckets(opts: {
|
||||
// the user's zone and truncate there.
|
||||
//
|
||||
// The canonical source is chosen per local DAY, whatever the grain: each
|
||||
// source is folded per day in one hashed pass (a few rows per day, however
|
||||
// dense the stream), each day keeps its ladder-canonical source, and only
|
||||
// then are the days folded into the week or month. Picking the source per
|
||||
// source is folded per hour and then per day in hashed passes (a few rows
|
||||
// per day, however dense the stream), each day keeps its ladder-canonical
|
||||
// source, and only then are the days folded into the week or month. Picking the source per
|
||||
// week or month instead dropped every day the losing source carried, so a
|
||||
// month of manual readings plus one synced reading reported one day.
|
||||
// Folding first and picking second is the same result as collapsing the
|
||||
// raw rows first; it just never sorts more than one row per source per day.
|
||||
//
|
||||
// One chain of folds over one scan, no join. The hour means used to be a
|
||||
// second scan joined back to the per-source days before the pick; on a
|
||||
// measurements table without planner statistics (a new install, an import
|
||||
// under the autoanalyze threshold) that join ran as a nested loop that
|
||||
// re-ran the hour fold for every source day, and a ten-year read of every
|
||||
// type ran into the statement timeout.
|
||||
// `tests/integration/per-source-fold-parity.test.ts` holds the joined
|
||||
// statement as the reference.
|
||||
const buckets = await prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
@@ -234,13 +243,20 @@ export async function readLiveBuckets(opts: {
|
||||
max_value: number | null;
|
||||
}>
|
||||
>`
|
||||
WITH per_source AS (
|
||||
-- Each source's readings fold per local hour, then the hours per local
|
||||
-- day. A day of a type with an activity-dependent sampling rate is the
|
||||
-- weighted mean of its local hours' means, not the mean of its readings:
|
||||
-- a workout hour holds hundreds of times as many readings as a resting
|
||||
-- one. NULL for every other type.
|
||||
WITH per_hour AS (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS day,
|
||||
date_trunc('hour', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS hr,
|
||||
SUM(m."value")::double precision AS total,
|
||||
COUNT(*)::int AS cnt,
|
||||
COUNT(*) AS n,
|
||||
AVG(m."value") AS hour_mean,
|
||||
MIN(m."value") AS min_value,
|
||||
MAX(m."value") AS max_value
|
||||
FROM measurements m
|
||||
@@ -249,40 +265,25 @@ export async function readLiveBuckets(opts: {
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
${typeFilter}
|
||||
GROUP BY m."type", m."source", 3
|
||||
GROUP BY m."type", m."source", 3, 4
|
||||
),
|
||||
-- A day of a type with an activity-dependent sampling rate is the weighted
|
||||
-- mean of its local hours' means, not the mean of its readings: a workout
|
||||
-- hour holds hundreds of times as many readings as a resting one.
|
||||
-- Empty for every other type.
|
||||
hour_mean AS (
|
||||
SELECT h."type", h."source", h.day,
|
||||
${dayOfHours}::double precision AS day_mean
|
||||
FROM (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS day,
|
||||
date_trunc('hour', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS hr,
|
||||
AVG(m."value") AS hour_mean,
|
||||
COUNT(*) AS n
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${hourlyMeanList})
|
||||
${typeFilter}
|
||||
GROUP BY m."type", m."source", 3, 4
|
||||
) h
|
||||
per_source AS (
|
||||
SELECT
|
||||
h."type",
|
||||
h."source",
|
||||
h.day,
|
||||
SUM(h.total)::double precision AS total,
|
||||
SUM(h.n)::int AS cnt,
|
||||
MIN(h.min_value) AS min_value,
|
||||
MAX(h.max_value) AS max_value,
|
||||
(CASE WHEN h."type" IN (${hourlyMeanList}) THEN ${dayOfHours} END)::double precision AS day_mean
|
||||
FROM per_hour h
|
||||
GROUP BY h."type", h."source", h.day
|
||||
),
|
||||
canon AS (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.day, p.total, p.cnt, p.min_value, p.max_value, hm.day_mean
|
||||
p."type", p.day, p.total, p.cnt, p.min_value, p.max_value, p.day_mean
|
||||
FROM per_source p
|
||||
LEFT JOIN hour_mean hm
|
||||
ON hm."type" = p."type" AND hm."source" = p."source" AND hm.day = p.day
|
||||
ORDER BY p."type", p.day, (${rankRaw}), p."source"::text
|
||||
),
|
||||
folded AS (
|
||||
|
||||
@@ -32,7 +32,8 @@ export async function readBloodPressureSeries(
|
||||
// the whole edge day, not only the slice after `since`.
|
||||
//
|
||||
// `rank` is the closed-enum whitelist splice from `buildSourceRankCase`;
|
||||
// every request value is a positional parameter.
|
||||
// every request value is a positional parameter. The statement and why it
|
||||
// is a window rather than a join are under `bloodPressureSeriesSql`.
|
||||
const rank = buildSourceRankCase(
|
||||
await loadUserSourcePriority(userId),
|
||||
'"type"',
|
||||
@@ -47,33 +48,7 @@ export async function readBloodPressureSeries(
|
||||
measured_at: Date;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
SELECT m."id", m."type"::text AS type, m."source"::text AS source,
|
||||
m."value", m."measured_at"
|
||||
FROM measurements m
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (date_trunc('day', "measured_at"))
|
||||
date_trunc('day', "measured_at") AS d,
|
||||
"source" AS canon
|
||||
FROM measurements
|
||||
WHERE "user_id" = $1
|
||||
AND "type" = 'BLOOD_PRESSURE_SYS'::"measurement_type"
|
||||
AND "deleted_at" IS NULL
|
||||
AND "value" >= $3
|
||||
AND "measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC')
|
||||
ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text
|
||||
) c
|
||||
ON c.d = date_trunc('day', m."measured_at")
|
||||
AND c.canon = m."source"
|
||||
WHERE m."user_id" = $1
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= $2
|
||||
AND (
|
||||
(m."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND m."value" >= $3)
|
||||
OR (m."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND m."value" >= $4)
|
||||
)
|
||||
ORDER BY m."measured_at" ASC, m."id" ASC
|
||||
`,
|
||||
bloodPressureSeriesSql(rank),
|
||||
userId,
|
||||
since,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_SYS.min,
|
||||
@@ -125,3 +100,57 @@ export async function readBloodPressureSeries(
|
||||
});
|
||||
return { points };
|
||||
}
|
||||
|
||||
/**
|
||||
* The series statement: every floor-passing systolic and diastolic row from
|
||||
* `$2` on, of the source the ladder picks for its UTC day. `$1` is the user,
|
||||
* `$2` the window start, `$3` and `$4` the systolic and diastolic floors;
|
||||
* `rank` is a `buildSourceRankCase` CASE over unqualified `"type"` and
|
||||
* `"source"`.
|
||||
*
|
||||
* One scan reads the window from the start of its first UTC day, so the pick
|
||||
* sees the whole edge day. A window function names each day's source on
|
||||
* every row: the day's rows are ordered systolic first, then by ladder rank,
|
||||
* then by source name, and the first row's source is taken only when that
|
||||
* row is a systolic one, so a day with no floor-passing systolic picks no
|
||||
* source and shows nothing, as before. Rows of any other source, and rows
|
||||
* of the edge day before `$2`, are dropped after the pick.
|
||||
*
|
||||
* Why no join: the pick used to be a `DISTINCT ON` over the day's systolic
|
||||
* rows joined back to the readings. On a table without planner statistics
|
||||
* (a new install, an import still under the autoanalyze threshold) the
|
||||
* planner estimated one row on each side and ran that join as a nested loop
|
||||
* that rescanned the pick for every reading: ten years of readings cost
|
||||
* about 5,000 row visits per row. The window has no such choice to get
|
||||
* wrong; `tests/integration/canonical-source-pick-unanalysed.test.ts` holds
|
||||
* that, and `tests/integration/bp-series-pick-parity.test.ts` holds the old
|
||||
* statement as the reference for the rows it returns.
|
||||
*/
|
||||
export function bloodPressureSeriesSql(rank: string): string {
|
||||
return `
|
||||
SELECT w."id", w."type"::text AS type, w."source"::text AS source,
|
||||
w."value", w."measured_at"
|
||||
FROM (
|
||||
SELECT mm."id", mm."type", mm."source", mm."value", mm."measured_at",
|
||||
FIRST_VALUE(
|
||||
CASE WHEN mm."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type"
|
||||
THEN mm."source" END
|
||||
) OVER (
|
||||
PARTITION BY date_trunc('day', mm."measured_at")
|
||||
ORDER BY mm."type" <> 'BLOOD_PRESSURE_SYS'::"measurement_type",
|
||||
(${rank}), mm."source"::text
|
||||
) AS canonical_source
|
||||
FROM measurements mm
|
||||
WHERE mm."user_id" = $1
|
||||
AND mm."deleted_at" IS NULL
|
||||
AND mm."measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC')
|
||||
AND (
|
||||
(mm."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND mm."value" >= $3)
|
||||
OR (mm."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND mm."value" >= $4)
|
||||
)
|
||||
) w
|
||||
WHERE w."source" = w.canonical_source
|
||||
AND w."measured_at" >= $2
|
||||
ORDER BY w."measured_at" ASC, w."id" ASC
|
||||
`;
|
||||
}
|
||||
@@ -115,10 +115,12 @@ describe("the provenance query orders like the canonical pick", () => {
|
||||
`ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text`,
|
||||
),
|
||||
);
|
||||
// The shared pick groups to (type, day, source) first and orders the
|
||||
// groups by the same keys, the UTC day named `d`.
|
||||
// The shared pick partitions by type and UTC day and orders each day's
|
||||
// rows by the same keys.
|
||||
expect(squash(canonicalMeasurementsCte(rank))).toContain(
|
||||
squash(`ORDER BY "type", d, (${rank}), "source"::text`),
|
||||
squash(
|
||||
`PARTITION BY mm."type", date_trunc('day', mm."measured_at") ORDER BY (${rank}), mm."source"::text`,
|
||||
),
|
||||
);
|
||||
// Values are bound, never spliced.
|
||||
expect(userId).toBe("u1");
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* The blood-pressure series pick, against a real Postgres: the shipped
|
||||
* statement (one window function over one scan names each UTC day's source)
|
||||
* against the `DISTINCT ON` pick over the day's systolic rows joined back to
|
||||
* the readings, kept verbatim below as the reference.
|
||||
*
|
||||
* Both must return exactly the same rows in the same order, under the
|
||||
* default ladder and under user ladders that reorder it, for window starts
|
||||
* mid-day, on and either side of UTC midnight and on a local midnight, under
|
||||
* a UTC session and under sessions in other zones (the day cut reads the
|
||||
* session zone in both statements), over a fixture that carries what could
|
||||
* tell them apart: overlapping sources on one day, sources off the ladder
|
||||
* (the name tiebreak decides), a source whose only systolic that day is
|
||||
* below the floor, a diastolic below its floor, a diastolic with no
|
||||
* systolic, a systolic with no diastolic, tombstones, readings a minute
|
||||
* either side of UTC midnight, both DST changes in the user's zone, empty
|
||||
* days, and several readings per source per day.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import { bloodPressureSeriesSql } from "@/lib/measurements/series/read-blood-pressure";
|
||||
import { VALUE_RANGES } from "@/lib/validations/measurement";
|
||||
|
||||
import { makeUser } from "../kit/factories";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER_ID = "user-bp-series-parity";
|
||||
const DAY_MS = 86_400_000;
|
||||
const SYS_MIN = VALUE_RANGES.BLOOD_PRESSURE_SYS.min;
|
||||
const DIA_MIN = VALUE_RANGES.BLOOD_PRESSURE_DIA.min;
|
||||
|
||||
/** The statement as it stood before the window: a pick joined back. */
|
||||
function legacyBloodPressureSeriesSql(rank: string): string {
|
||||
return `
|
||||
SELECT m."id", m."type"::text AS type, m."source"::text AS source,
|
||||
m."value", m."measured_at"
|
||||
FROM measurements m
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (date_trunc('day', "measured_at"))
|
||||
date_trunc('day', "measured_at") AS d,
|
||||
"source" AS canon
|
||||
FROM measurements
|
||||
WHERE "user_id" = $1
|
||||
AND "type" = 'BLOOD_PRESSURE_SYS'::"measurement_type"
|
||||
AND "deleted_at" IS NULL
|
||||
AND "value" >= $3
|
||||
AND "measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC')
|
||||
ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text
|
||||
) c
|
||||
ON c.d = date_trunc('day', m."measured_at")
|
||||
AND c.canon = m."source"
|
||||
WHERE m."user_id" = $1
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= $2
|
||||
AND (
|
||||
(m."type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND m."value" >= $3)
|
||||
OR (m."type" = 'BLOOD_PRESSURE_DIA'::"measurement_type" AND m."value" >= $4)
|
||||
)
|
||||
ORDER BY m."measured_at" ASC, m."id" ASC
|
||||
`;
|
||||
}
|
||||
|
||||
/** Small seeded generator, so the fixture is the same on every run. */
|
||||
function rng(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const SOURCES: MeasurementSource[] = [
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
"MANUAL",
|
||||
// Off the blood-pressure ladder: rank alike, the name tiebreak decides.
|
||||
"OURA",
|
||||
"WHOOP",
|
||||
];
|
||||
|
||||
const NOW = Date.now();
|
||||
const TODAY_UTC = Math.floor(NOW / DAY_MS) * DAY_MS;
|
||||
|
||||
type Row = {
|
||||
userId: string;
|
||||
type: "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA";
|
||||
source: MeasurementSource;
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
deletedAt: Date | null;
|
||||
};
|
||||
|
||||
async function seed(): Promise<number> {
|
||||
const prisma = getPrismaClient();
|
||||
await makeUser({ id: USER_ID, timezone: "Europe/Berlin" });
|
||||
const random = rng(7);
|
||||
const rows: Row[] = [];
|
||||
const push = (
|
||||
type: Row["type"],
|
||||
source: MeasurementSource,
|
||||
value: number,
|
||||
at: number,
|
||||
) =>
|
||||
rows.push({
|
||||
userId: USER_ID,
|
||||
type,
|
||||
source,
|
||||
value,
|
||||
unit: "mmHg",
|
||||
measuredAt: new Date(at),
|
||||
// A tombstone, sometimes the only row its source had that day.
|
||||
deletedAt: random() < 0.06 ? new Date() : null,
|
||||
});
|
||||
// 400 days: over both DST changes in the user's zone.
|
||||
for (let day = 0; day < 400; day++) {
|
||||
if (day % 13 === 6) continue; // an empty day
|
||||
const dayStart = TODAY_UTC - day * DAY_MS;
|
||||
const sources = [...SOURCES]
|
||||
.sort(() => random() - 0.5)
|
||||
.slice(0, 1 + Math.floor(random() * 3));
|
||||
for (const source of sources) {
|
||||
const readings = 1 + Math.floor(random() * 3);
|
||||
for (let r = 0; r < readings; r++) {
|
||||
// Mostly through the day, sometimes a minute either side of UTC
|
||||
// midnight.
|
||||
const at =
|
||||
dayStart +
|
||||
(r === 0 && random() < 0.25
|
||||
? random() < 0.5
|
||||
? 60_000
|
||||
: DAY_MS - 60_000
|
||||
: Math.floor(random() * DAY_MS));
|
||||
const shape = random();
|
||||
if (shape < 0.08) {
|
||||
// A systolic below the floor: it must not claim the day.
|
||||
push(
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
source,
|
||||
Math.floor(random() * SYS_MIN),
|
||||
at,
|
||||
);
|
||||
push("BLOOD_PRESSURE_DIA", source, 80, at);
|
||||
} else if (shape < 0.14) {
|
||||
// A diastolic below its floor.
|
||||
push("BLOOD_PRESSURE_SYS", source, 125, at);
|
||||
push(
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
source,
|
||||
Math.floor(random() * DIA_MIN),
|
||||
at,
|
||||
);
|
||||
} else if (shape < 0.2) {
|
||||
// A diastolic alone: it never picks a source.
|
||||
push("BLOOD_PRESSURE_DIA", source, 78, at);
|
||||
} else if (shape < 0.25) {
|
||||
// A systolic alone.
|
||||
push("BLOOD_PRESSURE_SYS", source, 131, at);
|
||||
} else {
|
||||
const sys = 100 + Math.round(random() * 600) / 10;
|
||||
push("BLOOD_PRESSURE_SYS", source, sys, at);
|
||||
push(
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
source,
|
||||
60 + Math.round(random() * 300) / 10,
|
||||
at + Math.floor(random() * 4) * 60_000,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The natural identity is unique; drop accidental collisions.
|
||||
const seen = new Set<string>();
|
||||
const unique = rows.filter((row) => {
|
||||
const key = `${row.type}|${row.source}|${row.measuredAt.getTime()}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
await prisma.measurement.createMany({ data: unique });
|
||||
return unique.length;
|
||||
}
|
||||
|
||||
const LADDERS: Array<[string, unknown]> = [
|
||||
["default ladder", null],
|
||||
[
|
||||
"a user ladder that reorders it",
|
||||
{ bloodPressure: ["MANUAL", "APPLE_HEALTH", "WITHINGS"] },
|
||||
],
|
||||
];
|
||||
|
||||
const STARTS: Array<[string, Date]> = [
|
||||
["ten years", new Date(NOW - 3650 * DAY_MS)],
|
||||
["a year, mid-day", new Date(TODAY_UTC - 365 * DAY_MS + 13 * 3_600_000)],
|
||||
["thirty days, on UTC midnight", new Date(TODAY_UTC - 30 * DAY_MS)],
|
||||
[
|
||||
"a week, a minute before UTC midnight",
|
||||
new Date(TODAY_UTC - 7 * DAY_MS - 60_000),
|
||||
],
|
||||
[
|
||||
"a week, a minute after UTC midnight",
|
||||
new Date(TODAY_UTC - 7 * DAY_MS + 60_000),
|
||||
],
|
||||
// 22:00 UTC is Berlin midnight in summer, 23:00 in winter.
|
||||
[
|
||||
"ninety days, on a local midnight",
|
||||
new Date(TODAY_UTC - 91 * DAY_MS + 22 * 3_600_000),
|
||||
],
|
||||
["two days, mid-day", new Date(TODAY_UTC - 2 * DAY_MS + 9 * 3_600_000)],
|
||||
];
|
||||
|
||||
const ZONES = [
|
||||
"UTC",
|
||||
"Europe/Berlin",
|
||||
"Pacific/Auckland",
|
||||
"America/Los_Angeles",
|
||||
];
|
||||
|
||||
describe("blood-pressure series pick parity", () => {
|
||||
let seeded = 0;
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
seeded = await seed();
|
||||
});
|
||||
|
||||
it("seeds a fixture worth comparing", () => {
|
||||
expect(seeded).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
for (const [ladderLabel, ladder] of LADDERS) {
|
||||
for (const zone of ZONES) {
|
||||
for (const [startLabel, since] of STARTS) {
|
||||
it(`returns the same rows: ${ladderLabel}, ${zone} session, ${startLabel}`, async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const rank = buildSourceRankCase(ladder, '"type"', '"source"');
|
||||
const select = (sql: string) =>
|
||||
prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL TIME ZONE '${zone}'`);
|
||||
return tx.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
source: string;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
}>
|
||||
>(sql, USER_ID, since, SYS_MIN, DIA_MIN);
|
||||
});
|
||||
const now = await select(bloodPressureSeriesSql(rank));
|
||||
const before = await select(legacyBloodPressureSeriesSql(rank));
|
||||
expect(now.length).toBeGreaterThan(0);
|
||||
expect(now).toEqual(before);
|
||||
// Not trivial: both halves came back, from more than one source
|
||||
// over the window, and the pick dropped rows that were live,
|
||||
// floor-passing and inside the window.
|
||||
expect(new Set(now.map((r) => r.type)).size).toBe(2);
|
||||
const live = await prisma.$queryRawUnsafe<Array<{ n: number }>>(
|
||||
`SELECT COUNT(*)::int AS n FROM measurements
|
||||
WHERE "user_id" = $1 AND "deleted_at" IS NULL
|
||||
AND "measured_at" >= $2
|
||||
AND (("type" = 'BLOOD_PRESSURE_SYS' AND "value" >= $3)
|
||||
OR ("type" = 'BLOOD_PRESSURE_DIA' AND "value" >= $4))`,
|
||||
USER_ID,
|
||||
since,
|
||||
SYS_MIN,
|
||||
DIA_MIN,
|
||||
);
|
||||
expect(now.length).toBeLessThan(live[0].n);
|
||||
if (since.getTime() < TODAY_UTC - 30 * DAY_MS) {
|
||||
expect(new Set(now.map((r) => r.source)).size).toBeGreaterThan(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* The canonical-source pick, against a real Postgres: the shipped statement
|
||||
* (group the window to `(type, day, source)` first, then `DISTINCT ON`)
|
||||
* against the statement it replaced (`DISTINCT ON` over every raw row),
|
||||
* kept verbatim below as the reference. Only its window anchor changed: it
|
||||
* (one window function over one scan names each day's source) against the
|
||||
* `DISTINCT ON` pick joined back to the rows, kept verbatim below as the
|
||||
* reference. Only its window anchor changed: it
|
||||
* counts from the same bound instant the shipped helper takes, where it read
|
||||
* `NOW()`, so both cut one window.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* The canonical-source pick (`canonicalMeasurementsCte`) must not depend on
|
||||
* planner statistics. A new install, or an account whose import is still
|
||||
* under the autoanalyze threshold, reads against a `measurements` table the
|
||||
* planner has no statistics for. The pick used to join its per-day choices
|
||||
* back to the rows; on such a table the planner estimated one row on each
|
||||
* side and ran the join as a nested loop, rescanning the whole pick for every
|
||||
* reading. `GET /api/analytics` on 6,000 blood-pressure rows took seconds,
|
||||
* and on a ten-year account the all-time read ran past the statement timeout.
|
||||
*
|
||||
* Latency on a testcontainer says little, and the statement count did not
|
||||
* move at all, so this file holds the work instead: every reader shape the
|
||||
* helper serves is run under `EXPLAIN ANALYZE` on a table with no statistics,
|
||||
* and the rows its plan nodes produce or discard, times their loops, must
|
||||
* stay within a small multiple of the rows seeded. A plan that revisits the
|
||||
* table per row lands thousands of times over that budget, whatever the
|
||||
* machine.
|
||||
*
|
||||
* The same holds for the readers that fold per source and day without the
|
||||
* helper (the blood-pressure series, the live buckets, the all-time
|
||||
* extremes, the doctor report's dense buckets): each used to join a second
|
||||
* grouping of the same rows back to its days, which the planner ran the same
|
||||
* way on such a table.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
Prisma,
|
||||
} from "@/generated/prisma/client";
|
||||
import {
|
||||
boundInstantSql,
|
||||
instantLiteralSql,
|
||||
} from "@/lib/analytics/request-clock-sql";
|
||||
import {
|
||||
buildSourceRankCase,
|
||||
canonicalMeasurementsCte,
|
||||
type CanonicalWindow,
|
||||
} from "@/lib/analytics/source-rank-sql";
|
||||
import { prisma as appPrisma } from "@/lib/db";
|
||||
import { loadDenseMeasurementBuckets } from "@/lib/doctor-report/dense-buckets";
|
||||
import { readAllTimeExtremes } from "@/lib/insights/feature-blocks";
|
||||
import { readLiveBuckets } from "@/lib/measurements/daily-series-read";
|
||||
import { bloodPressureSeriesSql } from "@/lib/measurements/series/read-blood-pressure";
|
||||
import { VALUE_RANGES } from "@/lib/validations/measurement";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "canonical-pick-unanalysed";
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* Rows produced or discarded, summed over the plan, per row seeded. The
|
||||
* window pick sits at 4 or under on every shape below; the nested loop this
|
||||
* guards against measured 200 on the 90-day read and over 3,000 on the
|
||||
* one-type read, and ran past a minute on the all-time read.
|
||||
*/
|
||||
const WORK_PER_ROW = 8;
|
||||
|
||||
/**
|
||||
* The same for a reader that folds every source's rows per hour or per day
|
||||
* before it picks: a few more passes over the rows (a sort and a grouping
|
||||
* per level), so about 10 per row seeded. The joins these readers used to
|
||||
* make measured 200 to 3,000.
|
||||
*/
|
||||
const FOLD_WORK_PER_ROW = 16;
|
||||
|
||||
/**
|
||||
* Long enough for any linear plan on this fixture many times over, short
|
||||
* enough that a quadratic one fails here instead of at the test timeout.
|
||||
*/
|
||||
const STATEMENT_TIMEOUT = "10s";
|
||||
|
||||
/**
|
||||
* Ten years of paired blood pressure from one source with a second source on
|
||||
* every third day, and a year of pulse from two sources a day: overlapping
|
||||
* sources, so the pick has days to decide.
|
||||
*/
|
||||
function seedRows(): Prisma.MeasurementCreateManyInput[] {
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
const now = Date.now();
|
||||
const push = (
|
||||
type: MeasurementType,
|
||||
source: MeasurementSource,
|
||||
value: number,
|
||||
at: number,
|
||||
) =>
|
||||
rows.push({
|
||||
userId: USER,
|
||||
type,
|
||||
source,
|
||||
value,
|
||||
unit: type === "PULSE" ? "bpm" : "mmHg",
|
||||
measuredAt: new Date(at),
|
||||
});
|
||||
for (let d = 0; d < 3000; d += 1) {
|
||||
const at = now - (d + 0.25) * DAY;
|
||||
push("BLOOD_PRESSURE_SYS", "WITHINGS", 120 + (d % 30), at);
|
||||
push("BLOOD_PRESSURE_DIA", "WITHINGS", 75 + (d % 15), at);
|
||||
if (d % 3 === 0) {
|
||||
push("BLOOD_PRESSURE_SYS", "MANUAL", 130 + (d % 20), at + 600_000);
|
||||
push("BLOOD_PRESSURE_DIA", "MANUAL", 80 + (d % 10), at + 600_000);
|
||||
}
|
||||
}
|
||||
for (let d = 0; d < 365; d += 1) {
|
||||
for (const h of [7, 13, 21]) {
|
||||
const at = now - d * DAY - h * 3_600_000;
|
||||
push("PULSE", "WITHINGS", 60 + ((d + h) % 20), at);
|
||||
push("PULSE", "APPLE_HEALTH", 62 + ((d + h) % 18), at + 60_000);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
interface PlanNode {
|
||||
"Actual Rows"?: number;
|
||||
"Actual Loops"?: number;
|
||||
"Rows Removed by Filter"?: number;
|
||||
"Rows Removed by Join Filter"?: number;
|
||||
"Rows Removed by Index Recheck"?: number;
|
||||
Plans?: PlanNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row a node returned or threw away, over all its loops. EXPLAIN
|
||||
* reports both per loop, so a node rescanned once per outer row counts every
|
||||
* rescan.
|
||||
*/
|
||||
function rowVisits(node: PlanNode): number {
|
||||
const perLoop =
|
||||
(node["Actual Rows"] ?? 0) +
|
||||
(node["Rows Removed by Filter"] ?? 0) +
|
||||
(node["Rows Removed by Join Filter"] ?? 0) +
|
||||
(node["Rows Removed by Index Recheck"] ?? 0);
|
||||
const own = perLoop * (node["Actual Loops"] ?? 1);
|
||||
return own + (node.Plans ?? []).reduce((sum, p) => sum + rowVisits(p), 0);
|
||||
}
|
||||
|
||||
const NOW = new Date();
|
||||
|
||||
const SHAPES: Array<
|
||||
[
|
||||
label: string,
|
||||
window: CanonicalWindow | undefined,
|
||||
type: MeasurementType | undefined,
|
||||
from: Date | undefined,
|
||||
]
|
||||
> = [
|
||||
// The summaries slice's all-time read.
|
||||
[
|
||||
"every type, 15 years",
|
||||
{ from: boundInstantSql(2), interval: "15 years" },
|
||||
undefined,
|
||||
NOW,
|
||||
],
|
||||
// The summaries slice's and the comprehensive aggregator's 90-day reads.
|
||||
[
|
||||
"every type, 90 days",
|
||||
{ from: boundInstantSql(2), interval: "90 days" },
|
||||
undefined,
|
||||
NOW,
|
||||
],
|
||||
// The BP-in-target fast path: from its 365-day start, no interval.
|
||||
[
|
||||
"every type, from an instant",
|
||||
{ from: boundInstantSql(2) },
|
||||
undefined,
|
||||
new Date(NOW.getTime() - 365 * DAY),
|
||||
],
|
||||
// A metric series: one type, from a literal instant one day wider.
|
||||
[
|
||||
"one type, ten years",
|
||||
{
|
||||
from: instantLiteralSql(new Date(NOW.getTime() - 3650 * DAY)),
|
||||
interval: "1 day",
|
||||
},
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
undefined,
|
||||
],
|
||||
["every type, no window", undefined, undefined, undefined],
|
||||
];
|
||||
|
||||
let seeded = 0;
|
||||
|
||||
/**
|
||||
* Run `read` with every `$queryRaw` it issues also run under `EXPLAIN
|
||||
* ANALYZE` on a transaction with the statement timeout, and hold each plan to
|
||||
* the budget. The reader still gets its real rows.
|
||||
*/
|
||||
async function expectBoundedReader(
|
||||
read: () => Promise<unknown>,
|
||||
workPerRow: number,
|
||||
) {
|
||||
const original = appPrisma.$queryRaw.bind(appPrisma) as (
|
||||
...args: unknown[]
|
||||
) => Promise<unknown>;
|
||||
const statements: Array<[TemplateStringsArray, unknown[]]> = [];
|
||||
vi.spyOn(appPrisma, "$queryRaw").mockImplementation(((
|
||||
strings: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
) => {
|
||||
statements.push([strings, values]);
|
||||
return original(strings, ...values);
|
||||
}) as never);
|
||||
try {
|
||||
await read();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
expect(statements.length).toBeGreaterThan(0);
|
||||
for (const [strings, values] of statements) {
|
||||
const head = "EXPLAIN (ANALYZE, FORMAT JSON) ";
|
||||
const explain = Object.assign([head + strings[0], ...strings.slice(1)], {
|
||||
raw: [head + strings.raw[0], ...strings.raw.slice(1)],
|
||||
});
|
||||
const [{ "QUERY PLAN": plan }] = await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe(
|
||||
`SET LOCAL statement_timeout = '${STATEMENT_TIMEOUT}'`,
|
||||
);
|
||||
return tx.$queryRaw<Array<{ "QUERY PLAN": Array<{ Plan: PlanNode }> }>>(
|
||||
explain,
|
||||
...values,
|
||||
);
|
||||
});
|
||||
const visits = rowVisits(plan[0].Plan);
|
||||
expect(
|
||||
visits,
|
||||
`${visits} row visits for ${seeded} rows seeded:\n` +
|
||||
JSON.stringify(plan[0].Plan, null, 1),
|
||||
).toBeLessThanOrEqual(workPerRow * seeded);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `sql` under `EXPLAIN ANALYZE` and hold its row visits to the budget.
|
||||
* The statement timeout fails a quadratic plan here rather than at the test
|
||||
* timeout.
|
||||
*/
|
||||
async function expectBoundedPlan(sql: string, params: unknown[]) {
|
||||
const [{ "QUERY PLAN": plan }] = await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRawUnsafe(
|
||||
`SET LOCAL statement_timeout = '${STATEMENT_TIMEOUT}'`,
|
||||
);
|
||||
return tx.$queryRawUnsafe<
|
||||
Array<{ "QUERY PLAN": Array<{ Plan: PlanNode }> }>
|
||||
>(`EXPLAIN (ANALYZE, FORMAT JSON) ${sql}`, ...params);
|
||||
});
|
||||
const visits = rowVisits(plan[0].Plan);
|
||||
expect(
|
||||
visits,
|
||||
`${visits} row visits for ${seeded} rows seeded:\n` +
|
||||
JSON.stringify(plan[0].Plan, null, 1),
|
||||
).toBeLessThanOrEqual(WORK_PER_ROW * seeded);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.$executeRawUnsafe(
|
||||
"ALTER TABLE measurements SET (autovacuum_enabled = false)",
|
||||
);
|
||||
// Statistics another file in this worker gathered survive its TRUNCATE.
|
||||
await prisma.$executeRawUnsafe(
|
||||
"DELETE FROM pg_statistic WHERE starelid = 'measurements'::regclass",
|
||||
);
|
||||
await prisma.user.create({ data: { id: USER, username: USER } });
|
||||
const rows = seedRows();
|
||||
seeded = rows.length;
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$executeRawUnsafe(
|
||||
"ALTER TABLE measurements RESET (autovacuum_enabled)",
|
||||
);
|
||||
});
|
||||
|
||||
describe("canonical-source pick on a table without statistics", () => {
|
||||
it("runs against a table the planner has no statistics for", async () => {
|
||||
expect(seeded).toBeGreaterThan(10_000);
|
||||
const [stats] = await prisma.$queryRawUnsafe<
|
||||
Array<{ columns: number; reltuples: number }>
|
||||
>(
|
||||
`SELECT (SELECT COUNT(*) FROM pg_statistic
|
||||
WHERE starelid = 'measurements'::regclass)::int AS columns,
|
||||
reltuples::float8 AS reltuples
|
||||
FROM pg_class WHERE oid = 'measurements'::regclass`,
|
||||
);
|
||||
expect(stats).toEqual({ columns: 0, reltuples: -1 });
|
||||
});
|
||||
|
||||
it.each(SHAPES)(
|
||||
"reads the window a bounded number of times: %s",
|
||||
async (_label, window, onlyType, from) => {
|
||||
const rank = buildSourceRankCase(null, '"type"', '"source"');
|
||||
const sql = `WITH cm AS (${canonicalMeasurementsCte(rank, window, onlyType)}
|
||||
)
|
||||
SELECT COUNT(*)::int AS n, SUM(cm."value")::float8 AS total FROM cm`;
|
||||
const params: unknown[] = from ? [USER, from] : [USER];
|
||||
await expectBoundedPlan(sql, params);
|
||||
// The pick kept rows and dropped the second source's.
|
||||
const [{ n }] = await prisma.$queryRawUnsafe<Array<{ n: number }>>(
|
||||
sql,
|
||||
...params,
|
||||
);
|
||||
expect(n).toBeGreaterThan(0);
|
||||
expect(n).toBeLessThan(seeded);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The blood-pressure series picks its own source per day (from the day's
|
||||
* systolic rows above the floor, applied to both halves of the reading), so
|
||||
* it does not go through `canonicalMeasurementsCte` and needs its own row
|
||||
* here. Its windows are the series route's: ten years, a quarter, a week.
|
||||
* The `DISTINCT ON` pick joined back to the readings measured about 4,900
|
||||
* row visits per row seeded on the ten-year read; the window stays at 4.
|
||||
*/
|
||||
describe("blood-pressure series pick on a table without statistics", () => {
|
||||
it.each([
|
||||
["3650 days", 3650],
|
||||
["90 days", 90],
|
||||
["7 days", 7],
|
||||
])("reads the window a bounded number of times: %s", async (_label, days) => {
|
||||
const rank = buildSourceRankCase(null, '"type"', '"source"');
|
||||
const params: unknown[] = [
|
||||
USER,
|
||||
new Date(NOW.getTime() - days * DAY),
|
||||
VALUE_RANGES.BLOOD_PRESSURE_SYS.min,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_DIA.min,
|
||||
];
|
||||
const sql = bloodPressureSeriesSql(rank);
|
||||
await expectBoundedPlan(sql, params);
|
||||
// Both halves came back, and the second source's days were dropped.
|
||||
const rows = await prisma.$queryRawUnsafe<
|
||||
Array<{ type: string; source: string }>
|
||||
>(sql, ...params);
|
||||
expect(rows.some((r) => r.type === "BLOOD_PRESSURE_SYS")).toBe(true);
|
||||
expect(rows.some((r) => r.type === "BLOOD_PRESSURE_DIA")).toBe(true);
|
||||
expect(rows.every((r) => r.source === "WITHINGS")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The readers that fold each source's days themselves and then pick: the
|
||||
* live buckets of every type over ten years, the all-time extremes, and the
|
||||
* doctor report's dense buckets over ten years. Each used to join a second
|
||||
* grouping of the same rows (hour means, day weights, latest readings) back
|
||||
* to the per-source days; on this fixture those joins measured about 200
|
||||
* (dense buckets), 1,200 (extremes) and 3,000 (live buckets) row visits per
|
||||
* row seeded.
|
||||
*/
|
||||
describe("per-source folds on a table without statistics", () => {
|
||||
const from = new Date(NOW.getTime() - 3650 * DAY);
|
||||
it.each<[string, () => Promise<unknown[] | Map<unknown, unknown>>]>([
|
||||
[
|
||||
"live buckets, every type, ten years",
|
||||
() =>
|
||||
readLiveBuckets({
|
||||
userId: USER,
|
||||
type: null,
|
||||
from,
|
||||
to: NOW,
|
||||
cap: 100_000,
|
||||
priorityJson: null,
|
||||
grain: "daily",
|
||||
timeZone: "Europe/Berlin",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"all-time extremes",
|
||||
() =>
|
||||
readAllTimeExtremes(USER, [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"dense buckets, ten years",
|
||||
() =>
|
||||
loadDenseMeasurementBuckets({
|
||||
userId: USER,
|
||||
start: from,
|
||||
end: NOW,
|
||||
reportTz: "Europe/Berlin",
|
||||
denseTypes: ["PULSE"],
|
||||
}),
|
||||
],
|
||||
])("reads the window a bounded number of times: %s", async (_label, read) => {
|
||||
let out: unknown[] | Map<unknown, unknown> = [];
|
||||
await expectBoundedReader(async () => {
|
||||
out = await read();
|
||||
}, FOLD_WORK_PER_ROW);
|
||||
expect(out instanceof Map ? out.size : out.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,752 @@
|
||||
/**
|
||||
* Three per-source folds, against a real Postgres: each reader as it ships
|
||||
* (one fold over one scan) against the statement it replaced, kept verbatim
|
||||
* below as the reference, in which a second grouping of the same rows was
|
||||
* joined back to the per-source days or buckets.
|
||||
*
|
||||
* - `readLiveBuckets`: the local hours' means for an hourly-mean type,
|
||||
* joined to the per-source days before the ladder pick;
|
||||
* - `readAllTimeExtremes`: the day-weighted sums for an hourly-mean type,
|
||||
* joined to the per-source days before the ladder pick;
|
||||
* - `loadDenseMeasurementBuckets`: each bucket's latest reading and its
|
||||
* hour means, joined to the bucket aggregates.
|
||||
*
|
||||
* Every row must match: keys, counts, timestamps and extremes exactly, sums
|
||||
* and means to within floating-point summation order (each statement adds
|
||||
* the same readings, but not always in the same order or grouping, and
|
||||
* neither statement fixed that order). The fixture carries what could tell
|
||||
* them apart: overlapping sources per day, a source off every ladder, a
|
||||
* cumulative type, an hourly-mean type with several readings in one hour,
|
||||
* glucose contexts and device types including null ones, tombstones,
|
||||
* readings a minute either side of UTC midnight, both DST changes in the
|
||||
* user's zone, and empty days. Shapes run under the default ladders and a
|
||||
* user ladder, in zones with whole, half-hour and negative offsets.
|
||||
*
|
||||
* The fixture holds no rollups, so `readAllTimeExtremes` reads every type
|
||||
* from the raw rows; its rollup arm is a separate `UNION ALL` the change did
|
||||
* not touch.
|
||||
*/
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
Prisma,
|
||||
type GlucoseContext,
|
||||
type MeasurementSource,
|
||||
type MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import { prisma as appPrisma } from "@/lib/db";
|
||||
import { loadDenseMeasurementBuckets } from "@/lib/doctor-report/dense-buckets";
|
||||
import { readAllTimeExtremes } from "@/lib/insights/feature-blocks";
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { readLiveBuckets } from "@/lib/measurements/daily-series-read";
|
||||
import {
|
||||
dayWeightedRowsSql,
|
||||
foldMeanSql,
|
||||
hourWeightSql,
|
||||
isHourlyMeanTypeSql,
|
||||
weightedHourMeanSql,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import { HOURLY_MEAN_DAY_TYPES } from "@/lib/measurements/day-statistic";
|
||||
|
||||
import { makeUser } from "../kit/factories";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER_ID = "user-per-source-fold-parity";
|
||||
const DAY_MS = 86_400_000;
|
||||
const NOW = Date.now();
|
||||
const TODAY_UTC = Math.floor(NOW / DAY_MS) * DAY_MS;
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** The rows each `$queryRaw` the reader issued returned, in call order. */
|
||||
async function capture(read: () => Promise<unknown>): Promise<Row[][]> {
|
||||
const results: Row[][] = [];
|
||||
const original = appPrisma.$queryRaw.bind(appPrisma) as (
|
||||
...args: unknown[]
|
||||
) => Promise<Row[]>;
|
||||
vi.spyOn(appPrisma, "$queryRaw").mockImplementation(((...args: unknown[]) =>
|
||||
original(...args).then((rows) => {
|
||||
results.push(rows);
|
||||
return rows;
|
||||
})) as never);
|
||||
await read();
|
||||
return results;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Compare two row sets: numbers to a relative 1e-9, everything else exact. */
|
||||
function expectSameRows(now: Row[], before: Row[], keys: string[]) {
|
||||
const key = (r: Row) =>
|
||||
keys
|
||||
.map((k) =>
|
||||
r[k] instanceof Date ? (r[k] as Date).toISOString() : String(r[k]),
|
||||
)
|
||||
.join("|");
|
||||
const sort = (rows: Row[]) =>
|
||||
[...rows].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
||||
const a = sort(now);
|
||||
const b = sort(before);
|
||||
expect(a.map(key)).toEqual(b.map(key));
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
expect(Object.keys(a[i]).sort()).toEqual(Object.keys(b[i]).sort());
|
||||
for (const [col, value] of Object.entries(a[i])) {
|
||||
const other = b[i][col];
|
||||
if (typeof value === "number" && typeof other === "number") {
|
||||
expect(
|
||||
Math.abs(value - other),
|
||||
`${col} of ${key(a[i])}: ${value} vs ${other}`,
|
||||
).toBeLessThanOrEqual(1e-9 * Math.max(1, Math.abs(other)));
|
||||
} else {
|
||||
expect(value, `${col} of ${key(a[i])}`).toEqual(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Small seeded generator, so the fixture is the same on every run. */
|
||||
function rng(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const SOURCES: MeasurementSource[] = [
|
||||
"APPLE_HEALTH",
|
||||
"WITHINGS",
|
||||
"MANUAL",
|
||||
"OURA",
|
||||
// Off every ladder: the name tiebreak decides.
|
||||
"NIGHTSCOUT",
|
||||
];
|
||||
const TYPES: MeasurementType[] = [
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"WEIGHT",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
];
|
||||
const CONTEXTS: Array<GlucoseContext | null> = [null, "FASTING", "RANDOM"];
|
||||
const DEVICES: Array<string | null> = [null, "watch", "phone"];
|
||||
|
||||
async function seed(): Promise<number> {
|
||||
await makeUser({ id: USER_ID, timezone: "Europe/Berlin" });
|
||||
const random = rng(11);
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
// 400 days: over both DST changes in the user's zone.
|
||||
for (let day = 0; day < 400; day++) {
|
||||
if (day % 17 === 8) continue; // an empty day
|
||||
const dayStart = TODAY_UTC - day * DAY_MS;
|
||||
for (const type of TYPES) {
|
||||
const sources = [...SOURCES]
|
||||
.sort(() => random() - 0.5)
|
||||
.slice(0, 1 + Math.floor(random() * 3));
|
||||
for (const source of sources) {
|
||||
const readings = 1 + Math.floor(random() * 6);
|
||||
// Several readings share an hour, so an hour mean differs from a
|
||||
// reading mean.
|
||||
const hour = Math.floor(random() * 24);
|
||||
for (let r = 0; r < readings; r++) {
|
||||
const offset =
|
||||
r === 0 && random() < 0.2
|
||||
? random() < 0.5
|
||||
? 60_000
|
||||
: DAY_MS - 60_000
|
||||
: random() < 0.5
|
||||
? hour * 3_600_000 + Math.floor(random() * 3_600_000)
|
||||
: Math.floor(random() * DAY_MS);
|
||||
rows.push({
|
||||
userId: USER_ID,
|
||||
type,
|
||||
source,
|
||||
value: Math.round(random() * 2_000_000) / 10_000 + 1,
|
||||
unit: "x",
|
||||
measuredAt: new Date(dayStart + offset),
|
||||
deviceType: DEVICES[Math.floor(random() * DEVICES.length)],
|
||||
glucoseContext:
|
||||
type === "BLOOD_GLUCOSE"
|
||||
? CONTEXTS[Math.floor(random() * CONTEXTS.length)]
|
||||
: null,
|
||||
// A tombstone, sometimes the only row its source had that day.
|
||||
deletedAt: random() < 0.06 ? new Date() : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The natural identity is unique; drop accidental collisions.
|
||||
const seen = new Set<string>();
|
||||
const unique = rows.filter((row) => {
|
||||
const key = `${row.type}|${row.source}|${(row.measuredAt as Date).getTime()}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
await prisma.measurement.createMany({ data: unique });
|
||||
return unique.length;
|
||||
}
|
||||
|
||||
const LADDERS: Array<[string, Prisma.InputJsonValue | null]> = [
|
||||
["default ladders", null],
|
||||
[
|
||||
"a user ladder",
|
||||
{
|
||||
pulse: ["OURA", "WITHINGS", "APPLE_HEALTH"],
|
||||
weight: ["MANUAL", "WITHINGS"],
|
||||
steps: ["WITHINGS", "APPLE_HEALTH"],
|
||||
bloodPressure: ["APPLE_HEALTH", "MANUAL", "WITHINGS"],
|
||||
},
|
||||
],
|
||||
];
|
||||
const ZONES = ["Europe/Berlin", "America/Los_Angeles", "Asia/Kolkata"];
|
||||
|
||||
async function setLadder(ladder: Prisma.InputJsonValue | null) {
|
||||
await prisma.user.update({
|
||||
where: { id: USER_ID },
|
||||
data: { sourcePriorityJson: ladder ?? Prisma.DbNull },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The references, verbatim but for their inputs, which the readers build the
|
||||
// same way.
|
||||
|
||||
function legacyLiveBuckets(opts: {
|
||||
userId: string;
|
||||
type: MeasurementType | null;
|
||||
from: Date;
|
||||
to: Date;
|
||||
cap: number;
|
||||
priorityJson: unknown;
|
||||
unitName: string;
|
||||
timeZone: string;
|
||||
}) {
|
||||
const { userId, type, from, to, cap, priorityJson, timeZone } = opts;
|
||||
const unit = Prisma.raw(`'${opts.unitName}'`);
|
||||
const cumulativeList = [...CUMULATIVE_HK_TYPES]
|
||||
.map((t) => `'${t}'`)
|
||||
.join(",");
|
||||
const isCumulative = Prisma.raw(`c."type"::text IN (${cumulativeList})`);
|
||||
const hourlyMeanList = Prisma.raw(
|
||||
[...HOURLY_MEAN_DAY_TYPES].map((t) => `'${t}'::measurement_type`).join(","),
|
||||
);
|
||||
const rankRaw = Prisma.raw(
|
||||
buildSourceRankCase(priorityJson, 'p."type"', 'p."source"'),
|
||||
);
|
||||
const dayOfHours = Prisma.raw(
|
||||
weightedHourMeanSql("h.hour_mean", hourWeightSql("h.n")),
|
||||
);
|
||||
const typeFilter = type
|
||||
? Prisma.sql`AND m."type" = ${type}::measurement_type`
|
||||
: Prisma.empty;
|
||||
return prisma.$queryRaw<Row[]>`
|
||||
WITH per_source AS (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS day,
|
||||
SUM(m."value")::double precision AS total,
|
||||
COUNT(*)::int AS cnt,
|
||||
MIN(m."value") AS min_value,
|
||||
MAX(m."value") AS max_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
${typeFilter}
|
||||
GROUP BY m."type", m."source", 3
|
||||
),
|
||||
-- A day of a type with an activity-dependent sampling rate is the weighted
|
||||
-- mean of its local hours' means, not the mean of its readings: a workout
|
||||
-- hour holds hundreds of times as many readings as a resting one.
|
||||
-- Empty for every other type.
|
||||
hour_mean AS (
|
||||
SELECT h."type", h."source", h.day,
|
||||
${dayOfHours}::double precision AS day_mean
|
||||
FROM (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS day,
|
||||
date_trunc('hour', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS hr,
|
||||
AVG(m."value") AS hour_mean,
|
||||
COUNT(*) AS n
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${hourlyMeanList})
|
||||
${typeFilter}
|
||||
GROUP BY m."type", m."source", 3, 4
|
||||
) h
|
||||
GROUP BY h."type", h."source", h.day
|
||||
),
|
||||
canon AS (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.day, p.total, p.cnt, p.min_value, p.max_value, hm.day_mean
|
||||
FROM per_source p
|
||||
LEFT JOIN hour_mean hm
|
||||
ON hm."type" = p."type" AND hm."source" = p."source" AND hm.day = p.day
|
||||
ORDER BY p."type", p.day, (${rankRaw}), p."source"::text
|
||||
),
|
||||
folded AS (
|
||||
SELECT
|
||||
c."type",
|
||||
date_trunc(${unit}, c.day) AS d,
|
||||
(CASE
|
||||
WHEN ${isCumulative} THEN SUM(c.total)
|
||||
-- Hourly-mean types: every day once, so a window of days does not
|
||||
-- hand a workout day its thousands of readings as weight.
|
||||
WHEN COUNT(c.day_mean) = COUNT(*) THEN AVG(c.day_mean)
|
||||
ELSE SUM(c.total) / SUM(c.cnt)
|
||||
END)::double precision AS avg,
|
||||
SUM(c.cnt)::int AS cnt,
|
||||
MIN(c.min_value) AS min_value,
|
||||
MAX(c.max_value) AS max_value
|
||||
FROM canon c
|
||||
GROUP BY c."type", 2
|
||||
)
|
||||
SELECT
|
||||
f."type"::text AS type,
|
||||
f.d AT TIME ZONE ${timeZone} AS bucket_start,
|
||||
f.avg,
|
||||
f.cnt,
|
||||
f.min_value,
|
||||
f.max_value
|
||||
FROM folded f
|
||||
ORDER BY f.d ASC
|
||||
LIMIT ${cap}
|
||||
`;
|
||||
}
|
||||
|
||||
function legacyAllTimeExtremes(
|
||||
userId: string,
|
||||
types: readonly MeasurementType[],
|
||||
floor: Date | null,
|
||||
priorityJson: unknown,
|
||||
) {
|
||||
const typeList = (list: readonly MeasurementType[]) =>
|
||||
Prisma.join(list.map((t) => Prisma.sql`${t}::measurement_type`));
|
||||
const since =
|
||||
floor === null ? Prisma.empty : Prisma.sql`AND m."measured_at" >= ${floor}`;
|
||||
// No rollups in the fixture: every type is read raw, in one arm.
|
||||
const rawArms = [
|
||||
Prisma.sql`
|
||||
SELECT m."type", m."source", m."measured_at", m."value"
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${typeList(types)})
|
||||
${since}`,
|
||||
];
|
||||
const tierCanon = Prisma.empty;
|
||||
const rank = (alias: string) =>
|
||||
Prisma.raw(
|
||||
buildSourceRankCase(priorityJson, `${alias}."type"`, `${alias}."source"`),
|
||||
);
|
||||
const mean = Prisma.raw(
|
||||
foldMeanSql({
|
||||
typeColumn: 'c."type"',
|
||||
total: "c.total",
|
||||
count: "c.cnt",
|
||||
weightedSum: "c.wsum",
|
||||
weightSum: "c.wdays",
|
||||
}),
|
||||
);
|
||||
return prisma.$queryRaw<Row[]>`
|
||||
WITH live AS (${Prisma.join(rawArms, " UNION ALL ")}),
|
||||
hourly_src AS (
|
||||
SELECT * FROM live l
|
||||
WHERE ${Prisma.raw(isHourlyMeanTypeSql('l."type"'))}
|
||||
),
|
||||
weighted AS (
|
||||
SELECT
|
||||
w."type",
|
||||
w."source",
|
||||
date_trunc('day', w."measured_at") AS day,
|
||||
SUM(w."value" * w.day_weight)::double precision AS wsum,
|
||||
SUM(w.day_weight)::double precision AS wdays
|
||||
FROM ${dayWeightedRowsSql("hourly_src", null, { bySource: true })} w
|
||||
GROUP BY w."type", w."source", 3
|
||||
),
|
||||
per_source AS (
|
||||
SELECT
|
||||
l."type",
|
||||
l."source",
|
||||
date_trunc('day', l."measured_at") AS day,
|
||||
COUNT(*)::int AS cnt,
|
||||
SUM(l."value")::double precision AS total,
|
||||
MIN(l."value")::double precision AS min_value,
|
||||
MAX(l."value")::double precision AS max_value
|
||||
FROM live l
|
||||
GROUP BY l."type", l."source", 3
|
||||
)
|
||||
SELECT
|
||||
c."type"::text AS type,
|
||||
${mean}::double precision AS mean,
|
||||
MIN(c.min_value)::double precision AS min,
|
||||
MAX(c.max_value)::double precision AS max
|
||||
FROM (
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value, w.wsum, w.wdays
|
||||
FROM per_source p
|
||||
LEFT JOIN weighted w
|
||||
ON w."type" = p."type" AND w."source" = p."source" AND w.day = p.day
|
||||
ORDER BY p."type", p.day, (${rank("p")}), p."source"::text
|
||||
) raw
|
||||
${tierCanon}
|
||||
) c
|
||||
GROUP BY c."type"
|
||||
`;
|
||||
}
|
||||
|
||||
function legacyDenseBuckets(params: {
|
||||
userId: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
reportTz: string;
|
||||
denseTypes: readonly MeasurementType[];
|
||||
}) {
|
||||
const { userId, start, end, reportTz, denseTypes } = params;
|
||||
const typeList = Prisma.join(
|
||||
denseTypes.map((type) => Prisma.sql`${type}::"measurement_type"`),
|
||||
);
|
||||
const hourlyMeanType = Prisma.raw(isHourlyMeanTypeSql('"type"'));
|
||||
const hourWeight = Prisma.raw(hourWeightSql("n"));
|
||||
return prisma.$queryRaw<Row[]>`
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
m."id",
|
||||
m."type",
|
||||
m."source",
|
||||
m."device_type",
|
||||
m."glucose_context",
|
||||
m."value",
|
||||
m."measured_at",
|
||||
date_trunc(
|
||||
'day',
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${reportTz}
|
||||
) AS local_day,
|
||||
date_trunc(
|
||||
'hour',
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${reportTz}
|
||||
) AS local_hour
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${start}
|
||||
AND m."measured_at" <= ${end}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${typeList})
|
||||
),
|
||||
scored AS (
|
||||
SELECT
|
||||
localized.*,
|
||||
CASE
|
||||
WHEN "value" > 0
|
||||
THEN 1.509 * (power(ln("value"), 1.084) - 5.381)
|
||||
ELSE NULL
|
||||
END AS risk_transform
|
||||
FROM localized
|
||||
),
|
||||
latest_rows AS (
|
||||
SELECT DISTINCT ON (
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
)
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
"measured_at" AS latest_at,
|
||||
"value" AS latest_value
|
||||
FROM localized
|
||||
ORDER BY
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
"measured_at" DESC,
|
||||
"id" DESC
|
||||
),
|
||||
-- The hourly-mean day (pulse): each local hour's mean and reading
|
||||
-- count, then the weighted sum of the means and the sum of the hour
|
||||
-- weights per bucket. Empty for every other type.
|
||||
hour_means AS (
|
||||
SELECT
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
AVG("value") AS hour_mean,
|
||||
COUNT(*) AS n
|
||||
FROM localized
|
||||
WHERE ${hourlyMeanType}
|
||||
GROUP BY
|
||||
local_day,
|
||||
local_hour,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
),
|
||||
hour_agg AS (
|
||||
SELECT
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
SUM(hour_mean * ${hourWeight})::double precision AS hour_weighted_sum,
|
||||
SUM(${hourWeight})::double precision AS hour_weight
|
||||
FROM hour_means
|
||||
GROUP BY
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
),
|
||||
grouped AS (
|
||||
SELECT
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context",
|
||||
COUNT(*)::int AS sample_count,
|
||||
SUM("value")::double precision AS sum_value,
|
||||
MIN("value")::double precision AS min_value,
|
||||
MAX("value")::double precision AS max_value,
|
||||
COUNT(*) FILTER (WHERE "value" > 0)::int AS clinical_count,
|
||||
SUM("value") FILTER (WHERE "value" > 0)::double precision
|
||||
AS clinical_sum,
|
||||
SUM("value" * "value") FILTER (WHERE "value" > 0)::double precision
|
||||
AS clinical_sum_squares,
|
||||
MIN("measured_at") FILTER (WHERE "value" > 0) AS clinical_first_at,
|
||||
MAX("measured_at") FILTER (WHERE "value" > 0) AS clinical_last_at,
|
||||
COUNT(*) FILTER (WHERE "value" BETWEEN 70 AND 180)::int
|
||||
AS clinical_tir_count,
|
||||
COUNT(*) FILTER (WHERE "value" > 0 AND "value" < 70)::int
|
||||
AS clinical_tbr1_count,
|
||||
COUNT(*) FILTER (WHERE "value" > 0 AND "value" < 54)::int
|
||||
AS clinical_tbr2_count,
|
||||
COUNT(*) FILTER (WHERE "value" > 180)::int
|
||||
AS clinical_tar1_count,
|
||||
COUNT(*) FILTER (WHERE "value" > 250)::int
|
||||
AS clinical_tar2_count,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN risk_transform < 0
|
||||
THEN 10 * risk_transform * risk_transform
|
||||
ELSE 0
|
||||
END
|
||||
) FILTER (WHERE risk_transform IS NOT NULL)::double precision
|
||||
AS clinical_low_risk_sum,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN risk_transform > 0
|
||||
THEN 10 * risk_transform * risk_transform
|
||||
ELSE 0
|
||||
END
|
||||
) FILTER (WHERE risk_transform IS NOT NULL)::double precision
|
||||
AS clinical_high_risk_sum
|
||||
FROM scored
|
||||
GROUP BY
|
||||
local_day,
|
||||
"type",
|
||||
"source",
|
||||
"device_type",
|
||||
"glucose_context"
|
||||
)
|
||||
SELECT
|
||||
grouped.local_day AT TIME ZONE ${reportTz} AS "bucketStart",
|
||||
grouped."type",
|
||||
grouped."source",
|
||||
grouped."device_type" AS "deviceType",
|
||||
grouped."glucose_context" AS "glucoseContext",
|
||||
grouped.sample_count AS "count",
|
||||
grouped.sum_value AS "sumValue",
|
||||
grouped.min_value AS "minValue",
|
||||
grouped.max_value AS "maxValue",
|
||||
latest_rows.latest_at AS "latestAt",
|
||||
latest_rows.latest_value::double precision AS "latestValue",
|
||||
grouped.clinical_count AS "clinicalCount",
|
||||
grouped.clinical_sum AS "clinicalSum",
|
||||
grouped.clinical_sum_squares AS "clinicalSumSquares",
|
||||
grouped.clinical_first_at AS "clinicalFirstAt",
|
||||
grouped.clinical_last_at AS "clinicalLastAt",
|
||||
grouped.clinical_tir_count AS "clinicalTirCount",
|
||||
grouped.clinical_tbr1_count AS "clinicalTbr1Count",
|
||||
grouped.clinical_tbr2_count AS "clinicalTbr2Count",
|
||||
grouped.clinical_tar1_count AS "clinicalTar1Count",
|
||||
grouped.clinical_tar2_count AS "clinicalTar2Count",
|
||||
grouped.clinical_low_risk_sum AS "clinicalLowRiskSum",
|
||||
grouped.clinical_high_risk_sum AS "clinicalHighRiskSum",
|
||||
hour_agg.hour_weighted_sum AS "hourWeightedSum",
|
||||
hour_agg.hour_weight AS "hourWeight"
|
||||
FROM grouped
|
||||
INNER JOIN latest_rows
|
||||
ON latest_rows.local_day = grouped.local_day
|
||||
AND latest_rows."type" = grouped."type"
|
||||
AND latest_rows."source" = grouped."source"
|
||||
AND latest_rows."device_type" IS NOT DISTINCT FROM grouped."device_type"
|
||||
AND latest_rows."glucose_context" IS NOT DISTINCT FROM grouped."glucose_context"
|
||||
LEFT JOIN hour_agg
|
||||
ON hour_agg.local_day = grouped.local_day
|
||||
AND hour_agg."type" = grouped."type"
|
||||
AND hour_agg."source" = grouped."source"
|
||||
AND hour_agg."device_type" IS NOT DISTINCT FROM grouped."device_type"
|
||||
AND hour_agg."glucose_context" IS NOT DISTINCT FROM grouped."glucose_context"
|
||||
ORDER BY "bucketStart" ASC, grouped."type" ASC
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("per-source fold parity", () => {
|
||||
let seeded = 0;
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
seeded = await seed();
|
||||
}, 120_000);
|
||||
|
||||
it("seeds a fixture worth comparing", () => {
|
||||
expect(seeded).toBeGreaterThan(5000);
|
||||
});
|
||||
|
||||
describe("readLiveBuckets", () => {
|
||||
const SHAPES: Array<
|
||||
[string, MeasurementType | null, "daily" | "weekly" | "monthly", number]
|
||||
> = [
|
||||
["every type, daily, 400 days", null, "daily", 400],
|
||||
["every type, weekly, 400 days", null, "weekly", 400],
|
||||
["every type, monthly, 400 days", null, "monthly", 400],
|
||||
["pulse, daily, 30 days", "PULSE", "daily", 30],
|
||||
["steps, weekly, 120 days", "ACTIVITY_STEPS", "weekly", 120],
|
||||
];
|
||||
const UNIT = { daily: "day", weekly: "week", monthly: "month" } as const;
|
||||
for (const [ladderLabel, ladder] of LADDERS) {
|
||||
for (const zone of ZONES) {
|
||||
for (const [label, type, grain, days] of SHAPES) {
|
||||
it(`returns the same buckets: ${ladderLabel}, ${zone}, ${label}`, async () => {
|
||||
await setLadder(ladder);
|
||||
// A window edge in the middle of a day.
|
||||
const from = new Date(NOW - days * DAY_MS - 5 * 3_600_000);
|
||||
const to = new Date(NOW);
|
||||
const opts = {
|
||||
userId: USER_ID,
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
cap: 100_000,
|
||||
priorityJson: ladder,
|
||||
timeZone: zone,
|
||||
};
|
||||
const [now] = await capture(() =>
|
||||
readLiveBuckets({ ...opts, grain }),
|
||||
);
|
||||
const before = await legacyLiveBuckets({
|
||||
...opts,
|
||||
unitName: UNIT[grain],
|
||||
});
|
||||
expect(now.length).toBeGreaterThan(0);
|
||||
expectSameRows(now, before, ["type", "bucket_start", "cnt"]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("readAllTimeExtremes", () => {
|
||||
const TYPES_READ: MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"PULSE",
|
||||
"ACTIVITY_STEPS",
|
||||
];
|
||||
for (const [ladderLabel, ladder] of LADDERS) {
|
||||
for (const [floorLabel, floor] of [
|
||||
["all time", null],
|
||||
["from a floor mid-day", new Date(NOW - 90 * DAY_MS - 7 * 3_600_000)],
|
||||
] as const) {
|
||||
it(`returns the same figures: ${ladderLabel}, ${floorLabel}`, async () => {
|
||||
await setLadder(ladder);
|
||||
const results = await capture(() =>
|
||||
readAllTimeExtremes(USER_ID, TYPES_READ, floor),
|
||||
);
|
||||
// The tier check first, then the fold; no type came from the tier.
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual([]);
|
||||
const before = await legacyAllTimeExtremes(
|
||||
USER_ID,
|
||||
TYPES_READ,
|
||||
floor,
|
||||
ladder,
|
||||
);
|
||||
expect(results[1]).toHaveLength(TYPES_READ.length);
|
||||
expectSameRows(results[1], before, ["type"]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("loadDenseMeasurementBuckets", () => {
|
||||
const DENSE: MeasurementType[] = [
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
];
|
||||
for (const zone of ZONES) {
|
||||
for (const [label, days] of [
|
||||
["400 days", 400],
|
||||
["120 days", 120],
|
||||
] as const) {
|
||||
it(`returns the same buckets: ${zone}, ${label}`, async () => {
|
||||
const params = {
|
||||
userId: USER_ID,
|
||||
start: new Date(NOW - days * DAY_MS - 5 * 3_600_000),
|
||||
end: new Date(NOW),
|
||||
reportTz: zone,
|
||||
denseTypes: DENSE,
|
||||
};
|
||||
const now = (await loadDenseMeasurementBuckets(
|
||||
params,
|
||||
)) as unknown as Row[];
|
||||
const before = await legacyDenseBuckets(params);
|
||||
expect(now.length).toBeGreaterThan(0);
|
||||
// Some buckets carry an hour fold and some do not.
|
||||
expect(now.some((r) => r.hourWeight !== null)).toBe(true);
|
||||
expect(now.some((r) => r.hourWeight === null)).toBe(true);
|
||||
expectSameRows(now, before, [
|
||||
"bucketStart",
|
||||
"type",
|
||||
"source",
|
||||
"deviceType",
|
||||
"glucoseContext",
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user