diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f8e66ea..864fdd0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ ### Fixed +- **The dashboard opens faster on accounts with years of readings.** Two of the + reads behind the metric tiles were doing far more work than the handful of + rows they return. The first asked for the newest reading of every metric by + sorting the account's entire live measurement set and then keeping one row per + metric, which on a multi-year account means reading hundreds of thousands of + rows and sorting them on disk once the sort outgrows the memory Postgres gives + it. The second read the daily chart buckets without naming a metric, so the + rollup table's key could not be used and every stored bucket got scanned, + including the weekly, monthly and yearly ones the chart never shows. Both now + ask per metric, which is what the existing indexes are built for. On a fixture + matching the shape of a real account the first read went from 112 ms to 0.7 ms + and the second from 6.9 ms to 1.6 ms, and both stop growing with the size of + the archive. Nothing about the tiles changes: same numbers, same order, same + metrics. - **The check that watches for ungated module routes now watches every module.** It walked a hand-written list of route trees, and the list had stopped keeping up: the Coach tree, the environmental-context tree, the diff --git a/src/__tests__/dashboard-summary-read-plan-guard.test.ts b/src/__tests__/dashboard-summary-read-plan-guard.test.ts new file mode 100644 index 000000000..44c9707d8 --- /dev/null +++ b/src/__tests__/dashboard-summary-read-plan-guard.test.ts @@ -0,0 +1,106 @@ +/** + * Structural guards on the two per-type reads behind the dashboard tiles. + * + * A database audit against a multi-year account measured both of them as + * full passes over the largest tables on the dashboard path. The cause is + * the same in both cases: PostgreSQL 16 has no index skip scan, so a + * query that wants one row per measurement type but never names a type + * cannot be served by any index on `(user_id, type, …)` and falls back to + * reading everything the account owns. + * + * Neither shape is a bug a behavioural test can see — both return the + * right rows. What changes is the plan, and the plan is decided by two + * properties of the SQL text: that the latest-reading read is driven FROM + * the type list rather than grouped down to it, and that the rollup read + * carries a `type` predicate at all. So the guards assert on the SQL. + * + * They are tripwires, not proofs. They cannot show a plan is good — only + * that the two shapes the audit found have not come back. The measured + * plans live in the integration companion + * (`tests/integration/dashboard-summary-reads.test.ts`), which runs the + * same functions against a real Postgres. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const READS = join(process.cwd(), "src/lib/dashboard/summary-reads.ts"); +const ROUTE = join(process.cwd(), "src/app/api/dashboard/summary/route.ts"); + +/** + * Source with every comment removed, so a guard matches the SQL the + * database actually receives and never the prose explaining it. Both + * files discuss `DISTINCT ON` in their comments on purpose — that is the + * shape being warned about. + */ +function code(path: string): string { + return readFileSync(path, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +describe("dashboard summary reads — latest reading per type", () => { + it("does not group the account's whole live set down with DISTINCT ON", () => { + // The shape the audit measured: `SELECT DISTINCT ON (m."type") … FROM + // measurements`. Whitespace-tolerant, because a reformat must not be + // able to slip it back past the matcher. + const distinctOn = /DISTINCT\s+ON\s*\(/i; + expect(code(READS)).not.toMatch(distinctOn); + expect(code(ROUTE)).not.toMatch(distinctOn); + }); + + it("drives the read from the type list with a LATERAL lookup per type", () => { + const sql = code(READS); + const lateral = sql.match(/CROSS\s+JOIN\s+LATERAL/gi) ?? []; + expect(lateral.length).toBeGreaterThan(0); + // The type list has to reach SQL as the driving relation, and the + // per-type branch has to stop at the first row — without the LIMIT the + // LATERAL still reads every row of the type partition. + expect(sql).toMatch(/FROM\s+unnest\(\$\{/); + expect(sql).toMatch(/ORDER\s+BY\s+m\."measured_at"\s+DESC\s+LIMIT\s+1/); + }); +}); + +describe("dashboard summary reads — sparkline rollup buckets", () => { + it("carries a type predicate so the rollup key can drive the read", () => { + const sql = code(READS); + // `measurement_rollups` is keyed (user_id, type, granularity, + // bucket_start, source). Without an equality on `type` the key's + // second column is skipped and neither the primary key nor the + // user/type/granularity/bucket index can serve the read. + const rollupRead = sql.slice(sql.indexOf("FROM measurement_rollups r")); + expect(rollupRead.length).toBeGreaterThan(0); + const typePredicate = rollupRead.match(/r\."type"\s*=\s*ANY\(\$\{/g) ?? []; + expect(typePredicate.length).toBe(1); + // It has to sit alongside the user and granularity filters, not in a + // later subquery that the planner reaches only after the scan. + expect(rollupRead).toMatch( + /WHERE\s+r\."user_id"\s*=\s*\$\{[^}]+\}\s+AND\s+r\."type"\s*=\s*ANY\(/, + ); + }); + + it("binds the type list as a parameter rather than splicing it", () => { + // Raw SQL is parameter-bound or whitelist-spliced. The type list is a + // bound array with an explicit enum cast; a spliced list would also + // defeat statement reuse. + const sql = code(READS); + const casts = sql.match(/\}::"measurement_type"\[\]/g) ?? []; + expect(casts.length).toBe(2); + expect(sql).not.toMatch(/\$queryRawUnsafe/); + }); +}); + +describe("dashboard summary reads — the route keeps using them", () => { + it("calls the shared readers instead of inlining its own SQL", () => { + const route = code(ROUTE); + expect(route).toMatch(/readLatestEver\(prisma, userId, measurementTypes\)/); + expect(route).toMatch( + /readSparkBuckets\(\s*prisma,\s*userId,\s*measurementTypes,/, + ); + // No second copy of the rollup read can drift out of the guarded + // module. (`FROM measurements` still appears in the route's own + // streak-day aggregate, which is a bounded 365-row read and not part + // of this finding.) + expect(route.includes("FROM measurement_rollups")).toBe(false); + }); +}); diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 02970705e..b596e17fb 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -22,14 +22,16 @@ * - the set of YYYY-MM-DD day-keys with any activity in 365 days * * v1.4.38 swaps the two unbounded reads for SQL aggregates: - * - `DISTINCT ON (type)` → one row per type carrying the latest - * value + measuredAt (≤ N_metrics rows). REG-11 (v1.4.44) dropped - * the trailing-7-day window from the WHERE clause; the tile now - * surfaces the all-time-latest reading per type so a 60-day-old BP - * measurement still feeds the tile (paired with the stale-caption - * hint built off `lastSeenAt`). - * - `measurement_rollups` DAY buckets keyed `(user_id, granularity, - * bucketStart)` → at most 7 buckets per metric × N_metrics rows. + * - one row per type carrying the latest value + measuredAt + * (≤ N_metrics rows). REG-11 (v1.4.44) dropped the trailing-7-day + * window from the WHERE clause; the tile now surfaces the all-time- + * latest reading per type so a 60-day-old BP measurement still feeds + * the tile (paired with the stale-caption hint built off + * `lastSeenAt`). v1.38.5 replaced the `DISTINCT ON (type)` that + * produced those rows with a LATERAL join over the type list; both + * per-type reads now live in `@/lib/dashboard/summary-reads`. + * - `measurement_rollups` DAY buckets keyed `(user_id, type, + * granularity, bucketStart)` → at most 7 buckets per metric × N_metrics rows. * Sparkline points become the bucket means rather than individual * raw samples, which is a *smoother* trend signal for high-volume * metrics like ACTIVITY_STEPS and bounded for every other metric. @@ -63,6 +65,10 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; +import { + readLatestEver, + readSparkBuckets, +} from "@/lib/dashboard/summary-reads"; import { resolveModuleMap } from "@/lib/modules/gate"; import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules"; import { @@ -295,48 +301,6 @@ function computeStreak(activityDays: Set, userTz: string): StreakInfo { return { currentDays, longest }; } -/** v1.4.38 W-F — `DISTINCT ON (type)` row for the single most recent - * reading per measurement type, irrespective of age. One row per - * metric the user has ever touched; replaces the legacy unbounded - * `prisma.measurement.findMany`. - * - * REG-11 (v1.4.44): dropped the trailing-7-day window from the WHERE - * clause. The old shape returned `latestValue: null` + an empty - * sparkline for accounts whose last BP / pulse reading was older than - * 7 days, leaving the iOS tile blank even though the historical row - * was still in the database. The all-time aggregate (`groupBy` on - * `measurements`) already proves the row exists; the row count is - * still bounded by `|measurementTypes|` via `DISTINCT ON`. */ -interface LatestEverRow { - type: MeasurementType; - value: number; - measured_at: Date; -} - -/** v1.4.38 W-F — per-day measurement_rollup bucket feeding the dashboard - * sparkline. At most 7 buckets per metric × N metrics — bounded by - * `SPARK_DAYS * |measurementTypes|` rather than the raw row count. - * - * v1.4.39 W-SUM — `sum_value` rides along so the cumulative tile - * (ACTIVITY_STEPS) renders the daily SUM rather than the per-bucket - * MEAN. Spot metrics ignore the column; cumulative tiles fall back to - * `mean * count` when the legacy NULL hits (boot-backfill convergence - * window). - * - * REG-11 (v1.4.44): switched from a calendar-window filter - * (`bucket_start >= sevenDaysAgo`) to a `ROW_NUMBER() OVER (PARTITION - * BY type ORDER BY bucket_start DESC)` window so the sparkline takes - * the last `SPARK_DAYS` daily buckets per type regardless of age. An - * account whose last BP reading is 60 days old now still gets the - * trailing 7 days of historical buckets feeding the tile chart. */ -interface SparklineRow { - type: MeasurementType; - bucket_start: Date; - mean: number; - count: number; - sum_value: number | null; -} - /** v1.4.38 W-F — distinct activity day-keys from the streak window. * The route only needs YYYY-MM-DD strings, so the aggregate runs * `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns @@ -478,9 +442,9 @@ async function buildDashboardSummary( // v1.4.38 W-F — six bounded sub-queries replace the legacy 4 // unbounded ones. Row counts are now: - // - latestEver: ≤ N metric types (one row per type via DISTINCT ON, - // REG-11: dropped the 7-day window so a stale-but- - // valid historical reading still feeds the tile) + // - latestEver: ≤ N metric types (one indexed lookup per type via + // LATERAL, REG-11: dropped the 7-day window so a + // stale-but-valid historical reading still feeds the tile) // - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80, // REG-11: taken via ROW_NUMBER window, no calendar // filter, so a 60-day-old metric still paints a chart) @@ -498,43 +462,15 @@ async function buildDashboardSummary( measurementStreakDays, sleepStageRows, ] = await Promise.all([ - time( - "latestEver", - () => - prisma.$queryRaw` - SELECT DISTINCT ON (m."type") - m."type" AS type, - m."value"::double precision AS value, - m."measured_at" AS measured_at - FROM measurements m - WHERE m."user_id" = ${userId} - AND m."deleted_at" IS NULL - ORDER BY m."type", m."measured_at" DESC - `, - ), - time( - "sparkline", - () => - prisma.$queryRaw` - SELECT type, bucket_start, mean, count, sum_value - FROM ( - SELECT - r."type" AS type, - r."bucket_start" AS bucket_start, - r."mean"::double precision AS mean, - r."count"::int AS count, - r."sum_value"::double precision AS sum_value, - ROW_NUMBER() OVER ( - PARTITION BY r."type" - ORDER BY r."bucket_start" DESC - ) AS rn - FROM measurement_rollups r - WHERE r."user_id" = ${userId} - AND r."granularity" = 'DAY' - ) sub - WHERE rn <= ${SPARK_DAYS} - ORDER BY type, bucket_start ASC - `, + // Both reads live in `@/lib/dashboard/summary-reads` — the SQL is a + // contract the integration suite exercises directly against Postgres, + // and each carries the reason it names the type list at its + // definition. Short version: PostgreSQL 16 has no index skip scan, so + // "one row per type" has to be driven FROM the type list or it + // degrades to a full pass over the account. + time("latestEver", () => readLatestEver(prisma, userId, measurementTypes)), + time("sparkline", () => + readSparkBuckets(prisma, userId, measurementTypes, SPARK_DAYS), ), time("allTime", () => prisma.measurement.groupBy({ diff --git a/src/lib/dashboard/summary-reads.ts b/src/lib/dashboard/summary-reads.ts new file mode 100644 index 000000000..15fc47786 --- /dev/null +++ b/src/lib/dashboard/summary-reads.ts @@ -0,0 +1,161 @@ +/** + * The two per-type reads behind the dashboard summary's metric tiles. + * + * Both used to live inline in `src/app/api/dashboard/summary/route.ts`. + * They are lifted out for the same reason `buildMedsTodayBlock` is: the + * SQL is the contract, and a contract that only exists inside a route + * handler cannot be exercised against a real Postgres. The integration + * suite runs THESE functions, not a transcription of them. + * + * Both reads are shaped by one property of the schema: PostgreSQL 16 has + * no index skip scan. An index on `(user_id, type, …)` cannot hand back + * "one row per type" on its own — something has to supply the type list. + * So both queries name it. `measurementTypes` is the complete canonical + * enum, so naming it filters nothing out; it exists to give the planner + * an equality on the leading type column, which turns a full pass over + * the account into one bounded lookup per type. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import type { MeasurementType } from "@/generated/prisma/client"; + +/** v1.4.38 W-F — the single most recent reading per measurement + * type, irrespective of age. One row per metric the user has ever + * touched; replaces the legacy unbounded `prisma.measurement.findMany`. + * + * REG-11 (v1.4.44): dropped the trailing-7-day window from the WHERE + * clause. The old shape returned `latestValue: null` + an empty + * sparkline for accounts whose last BP / pulse reading was older than + * 7 days, leaving the iOS tile blank even though the historical row + * was still in the database. The all-time aggregate (`groupBy` on + * `measurements`) already proves the row exists; the row count is + * still bounded by `|measurementTypes|`, now because the query is + * driven FROM that list (v1.38.5) rather than grouped down to it. */ +export interface LatestEverRow { + type: MeasurementType; + value: number; + measured_at: Date; +} + +/** v1.4.38 W-F — per-day measurement_rollup bucket feeding the dashboard + * sparkline. At most `sparkDays` buckets per metric × N metrics — + * bounded by `sparkDays * |measurementTypes|` rather than the raw row + * count. + * + * v1.4.39 W-SUM — `sum_value` rides along so the cumulative tile + * (ACTIVITY_STEPS) renders the daily SUM rather than the per-bucket + * MEAN. Spot metrics ignore the column; cumulative tiles fall back to + * `mean * count` when the legacy NULL hits (boot-backfill convergence + * window). + * + * REG-11 (v1.4.44): switched from a calendar-window filter + * (`bucket_start >= sevenDaysAgo`) to a `ROW_NUMBER() OVER (PARTITION + * BY type ORDER BY bucket_start DESC)` window so the sparkline takes + * the last `sparkDays` daily buckets per type regardless of age. An + * account whose last BP reading is 60 days old now still gets the + * trailing 7 days of historical buckets feeding the tile chart. */ +export interface SparklineRow { + type: MeasurementType; + bucket_start: Date; + mean: number; + count: number; + sum_value: number | null; +} + +/** + * Latest live reading per measurement type. + * + * v1.38.5 — a LATERAL join over the type list, replacing the + * `DISTINCT ON (m."type")` this read used to be. + * + * The DISTINCT-ON shape has to produce every live row of the account in + * `(type, measured_at DESC)` order before it can throw all but the first + * of each group away. With no skip scan there is no index that hands it + * one row per type, so on a multi-year account it degrades to a + * sequential scan plus an in-memory sort that spills to disk once it + * outgrows `work_mem`. + * + * Driving the read from the type list instead makes it one index lookup + * per type against `measurements_live_covering_idx` (the partial + * `(user_id, type, measured_at DESC) WHERE deleted_at IS NULL` index from + * migration 0183), each stopping at the first row. `CROSS JOIN LATERAL` + * is an inner join, so a type with no live reading drops out exactly as + * DISTINCT ON dropped it, and `ORDER BY mt."type"` reproduces the enum + * ordering the old `ORDER BY m."type"` emitted. Same rows, same order. + * + * The driving relation is aliased `mt`, not `t`: a one-letter `t` alias + * makes `t("type")` in the SQL text look exactly like a `t()` translation + * call to the i18n call-site scanner, which then reports a missing key. + */ +export function readLatestEver( + db: PrismaClient, + userId: string, + measurementTypes: readonly MeasurementType[], +): Promise { + return db.$queryRaw` + SELECT + mt."type" AS type, + l."value" AS value, + l."measured_at" AS measured_at + FROM unnest(${[...measurementTypes]}::"measurement_type"[]) AS mt("type") + CROSS JOIN LATERAL ( + SELECT + m."value"::double precision AS value, + m."measured_at" AS measured_at + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type" = mt."type" + AND m."deleted_at" IS NULL + ORDER BY m."measured_at" DESC + LIMIT 1 + ) l + ORDER BY mt."type" + `; +} + +/** + * Trailing `sparkDays` DAY rollup buckets per measurement type. + * + * v1.38.5 — the `type` predicate is what makes this read indexable. + * + * `measurement_rollups` is keyed `(user_id, type, granularity, + * bucket_start, source)`. Filtering on `user_id` and `granularity` alone + * skips the second key column, so on a single-account host — where + * `user_id` selects nearly the whole table — neither the primary key nor + * `measurement_rollups_user_type_granularity_bucket_desc_idx` can drive + * the read, and Postgres falls back to a sequential scan over every + * granularity the account has ever rolled up. + * + * Naming the full enum restores the equality on `type` and the read + * becomes a bitmap index scan over the DAY slice. Deliberately NOT + * narrowed to the types the metric cards happen to render: the full enum + * keeps the returned row set provably identical to the unfiltered read, + * and a new measurement type stays included by construction. + */ +export function readSparkBuckets( + db: PrismaClient, + userId: string, + measurementTypes: readonly MeasurementType[], + sparkDays: number, +): Promise { + return db.$queryRaw` + SELECT type, bucket_start, mean, count, sum_value + FROM ( + SELECT + r."type" AS type, + r."bucket_start" AS bucket_start, + r."mean"::double precision AS mean, + r."count"::int AS count, + r."sum_value"::double precision AS sum_value, + ROW_NUMBER() OVER ( + PARTITION BY r."type" + ORDER BY r."bucket_start" DESC + ) AS rn + FROM measurement_rollups r + WHERE r."user_id" = ${userId} + AND r."type" = ANY(${[...measurementTypes]}::"measurement_type"[]) + AND r."granularity" = 'DAY' + ) sub + WHERE rn <= ${sparkDays} + ORDER BY type, bucket_start ASC + `; +} diff --git a/tests/integration/dashboard-summary-reads.test.ts b/tests/integration/dashboard-summary-reads.test.ts new file mode 100644 index 000000000..6dcf1f840 --- /dev/null +++ b/tests/integration/dashboard-summary-reads.test.ts @@ -0,0 +1,247 @@ +/** + * v1.38.5 — the dashboard's two per-type reads, against a real Postgres. + * + * Both were rewritten for their query plans, not for their results: the + * latest-reading read moved from `DISTINCT ON (type)` to a LATERAL join + * over the type list, and the sparkline read gained the `type` predicate + * its rollup key needs. Neither change may move a single row. + * + * So the reference implementation is the OLD SQL, kept verbatim in this + * file and run side by side with the shipped readers over the same + * fixture. The rows have to match exactly — value, ordering, and the + * types that are present at all. + * + * The fixture deliberately carries the three cases that could break the + * rewrite: a type whose only rows are tombstoned (must drop out of BOTH + * results, as an inner join and a filtered group both do), a type with + * no rows at all (same), and a rollup table holding coarser + * granularities the DAY read must not pick up. + * + * Plan shape is asserted by the unit companion + * (`src/__tests__/dashboard-summary-read-plan-guard.test.ts`) rather than + * here: a testcontainer fixture is far too small for the planner to + * choose the same access paths a real account produces, so an EXPLAIN + * assertion at this size would pin nothing. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + readLatestEver, + readSparkBuckets, + type LatestEverRow, + type SparklineRow, +} from "@/lib/dashboard/summary-reads"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import type { + MeasurementType, + RollupGranularity, +} from "@/generated/prisma/client"; + +const SPARK_DAYS = 7; +const TYPES = [...measurementTypeEnum.options] as MeasurementType[]; + +/** The pre-v1.38.5 latest-reading read, kept as the parity reference. */ +function legacyLatestEver(userId: string): Promise { + return getPrismaClient().$queryRaw` + SELECT DISTINCT ON (m."type") + m."type" AS type, + m."value"::double precision AS value, + m."measured_at" AS measured_at + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."deleted_at" IS NULL + ORDER BY m."type", m."measured_at" DESC + `; +} + +/** The pre-v1.38.5 sparkline read, without the `type` predicate. */ +function legacySparkBuckets(userId: string): Promise { + return getPrismaClient().$queryRaw` + SELECT type, bucket_start, mean, count, sum_value + FROM ( + SELECT + r."type" AS type, + r."bucket_start" AS bucket_start, + r."mean"::double precision AS mean, + r."count"::int AS count, + r."sum_value"::double precision AS sum_value, + ROW_NUMBER() OVER ( + PARTITION BY r."type" + ORDER BY r."bucket_start" DESC + ) AS rn + FROM measurement_rollups r + WHERE r."user_id" = ${userId} + AND r."granularity" = 'DAY' + ) sub + WHERE rn <= ${SPARK_DAYS} + ORDER BY type, bucket_start ASC + `; +} + +const DAY = 86_400_000; + +async function seedAccount(): Promise { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "summary-reads-user", + email: "summary-reads-user@example.test", + role: "USER", + }, + }); + + // Live readings across several types, several rows each, so the + // "latest wins" pick has something to choose between. + const live: MeasurementType[] = [ + "WEIGHT", + "PULSE", + "BLOOD_PRESSURE_SYS", + "ACTIVITY_STEPS", + "SLEEP_DURATION", + ]; + const base = Date.UTC(2026, 0, 1); + const rows = live.flatMap((type, ti) => + Array.from({ length: 12 }, (_, i) => ({ + userId: user.id, + type, + value: 50 + ti * 10 + i, + unit: "x", + source: "MANUAL" as const, + measuredAt: new Date(base + i * DAY + ti * 3_600_000), + deletedAt: null as Date | null, + })), + ); + // BODY_FAT exists only as tombstones: both reads must drop it. + rows.push( + ...Array.from({ length: 4 }, (_, i) => ({ + userId: user.id, + type: "BODY_FAT" as MeasurementType, + value: 20 + i, + unit: "%", + source: "MANUAL" as const, + measuredAt: new Date(base + i * DAY + 7_200_000), + deletedAt: new Date(base + 30 * DAY), + })), + ); + // A live type whose newest row is tombstoned — the read must fall back + // to the newest surviving row, not report the deleted one. + rows.push( + { + userId: user.id, + type: "BLOOD_GLUCOSE" as MeasurementType, + value: 95, + unit: "mg/dL", + source: "MANUAL" as const, + measuredAt: new Date(base + 5 * DAY), + deletedAt: null, + }, + { + userId: user.id, + type: "BLOOD_GLUCOSE" as MeasurementType, + value: 999, + unit: "mg/dL", + source: "MANUAL" as const, + measuredAt: new Date(base + 9 * DAY), + deletedAt: new Date(base + 30 * DAY), + }, + ); + await prisma.measurement.createMany({ data: rows }); + + // DAY buckets past the SPARK_DAYS window, plus coarser granularities + // the DAY read must ignore. + const bucketRows = live.flatMap((type, ti) => + Array.from({ length: 11 }, (_, i) => ({ + userId: user.id, + type, + granularity: "DAY" as RollupGranularity, + bucketStart: new Date(base + i * DAY), + source: "MANUAL" as const, + count: 3, + mean: 60 + ti + i, + minValue: 50, + maxValue: 70, + sumValue: 180 + ti, + })), + ); + bucketRows.push( + ...(["WEEK", "MONTH", "YEAR"] as const).flatMap((granularity) => + live.map((type, ti) => ({ + userId: user.id, + type, + granularity, + bucketStart: new Date(base + ti * DAY), + source: "MANUAL" as const, + count: 9, + mean: 1000 + ti, + minValue: 900, + maxValue: 1100, + sumValue: 9000 + ti, + })), + ), + ); + await prisma.measurementRollup.createMany({ data: bucketRows }); + + return user.id; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("dashboard summary reads — parity with the pre-v1.38.5 SQL", () => { + it("returns the identical latest-reading rows the DISTINCT ON returned", async () => { + const userId = await seedAccount(); + + const [legacy, shipped] = await Promise.all([ + legacyLatestEver(userId), + readLatestEver(getPrismaClient(), userId, TYPES), + ]); + + expect(shipped).toEqual(legacy); + // Not a vacuous pass: the fixture has to have produced rows. + expect(shipped.length).toBe(6); + // Tombstone-only type is absent from both; a type with no rows too. + expect(shipped.map((r) => r.type)).not.toContain("BODY_FAT"); + expect(shipped.map((r) => r.type)).not.toContain("VO2_MAX"); + // The newest LIVE glucose row wins, not the newer tombstoned one. + expect(shipped.find((r) => r.type === "BLOOD_GLUCOSE")?.value).toBe(95); + }); + + it("returns the identical sparkline buckets the type-less read returned", async () => { + const userId = await seedAccount(); + + const [legacy, shipped] = await Promise.all([ + legacySparkBuckets(userId), + readSparkBuckets(getPrismaClient(), userId, TYPES, SPARK_DAYS), + ]); + + expect(shipped).toEqual(legacy); + // Five types × the trailing SPARK_DAYS buckets, and nothing coarser. + expect(shipped.length).toBe(5 * SPARK_DAYS); + expect(shipped.every((r) => r.mean < 1000)).toBe(true); + }); + + it("orders both reads the way the callers' per-type maps expect", async () => { + const userId = await seedAccount(); + + const latest = await readLatestEver(getPrismaClient(), userId, TYPES); + // Enum declaration order, exactly as `ORDER BY m."type"` produced it. + const enumRank = new Map(TYPES.map((t, i) => [t, i])); + const ranks = latest.map((r) => enumRank.get(r.type) ?? -1); + expect(ranks).toEqual([...ranks].sort((a, b) => a - b)); + + const spark = await readSparkBuckets( + getPrismaClient(), + userId, + TYPES, + SPARK_DAYS, + ); + for (const type of new Set(spark.map((r) => r.type))) { + const stamps = spark + .filter((r) => r.type === type) + .map((r) => r.bucket_start.getTime()); + expect(stamps).toEqual([...stamps].sort((a, b) => a - b)); + } + }); +});