diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts index dca7b0525..7a494c151 100644 --- a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts +++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts @@ -112,7 +112,10 @@ describe("readDailySeries — long-range tier step-up", () => { }); expect(mocks.readTieredRollupSeries).toHaveBeenCalledTimes(1); - expect(mocks.readTieredRollupSeries.mock.calls[0][0].windowDays).toBe(3650); + // v1.26.0 SEAM-N2 — the resolved `[from, to]` bounds are threaded so the + // tier reads the REQUESTED window, not a trailing "to now" slice. + expect(mocks.readTieredRollupSeries.mock.calls[0][0].from).toBe(from); + expect(mocks.readTieredRollupSeries.mock.calls[0][0].to).toBe(to); // Whole-history coverage: the earliest 2017 bucket survives. expect(result[0].measuredAt).toBe("2017-01-01T00:00:00.000Z"); expect(result).toHaveLength(2); diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 8759c4e72..98cafad59 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -79,7 +79,12 @@ export async function readDailySeries(opts: { const tiered = await readTieredRollupSeries({ userId, type, - windowDays: windowDaysFull, + // v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier + // reads the REQUESTED window (which may be entirely historic), not a + // trailing "to now" slice. `windowDaysFull` still gates entry above and + // drives the tier width inside the reader. + from, + to, priorityJson, }); if (tiered && tiered.rows.length > 0) { diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index 733937bb6..67826add9 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -296,12 +296,23 @@ describe("readBestGranularityRollups — cross-consumer routing parity", () => { * miss; and that the wire shape mirrors the daily reader. */ describe("readTieredRollupSeries", () => { + const DAY_MS = 86_400_000; + // A fixed "now-ish" anchor for the current-window cases; the historic case + // uses its own explicit past bounds. Nothing in the reader reads + // `Date.now()` any more, so these are just span endpoints. + const NOW = new Date("2026-06-21T00:00:00.000Z"); + const win = (days: number, to: Date = NOW) => ({ + from: new Date(to.getTime() - days * DAY_MS), + to, + }); + it("returns null on a non-positive window without touching the db", async () => { expect( await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 0, + from: NOW, + to: NOW, }), ).toBeNull(); expect(findMany).not.toHaveBeenCalled(); @@ -320,7 +331,7 @@ describe("readTieredRollupSeries", () => { const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 3650, + ...win(3650), }); expect(result).not.toBeNull(); @@ -332,12 +343,59 @@ describe("readTieredRollupSeries", () => { expect(result?.rows.length).toBe(10); }); + // v1.26.0 SEAM-N2 — a historic window entirely in the past must bound the + // rollup read on BOTH ends. Pre-fix the reader anchored on `now − windowDays` + // and would have queried `bucketStart >= now − 730d` (mid-2024 onward), + // returning recent buckets for a 2020–2022 ask. + it("bounds the rollup read on BOTH ends for a historic past window", async () => { + const from = new Date("2020-01-01T00:00:00.000Z"); + const to = new Date("2022-01-01T00:00:00.000Z"); + const inWindow: RollupBucketRow[] = [ + bucket("2020-02-01T00:00:00.000Z", { count: 12, mean: 80 }), + bucket("2021-06-01T00:00:00.000Z", { count: 12, mean: 81 }), + bucket("2021-12-01T00:00:00.000Z", { count: 12, mean: 82 }), + ]; + findMany.mockResolvedValueOnce(inWindow); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + from, + to, + }); + + // The `bucketStart` filter carries BOTH the requested lower AND upper + // bound — not a trailing "to now" slice. + const where = findMany.mock.calls[0][0].where; + expect(where.bucketStart).toEqual({ gte: from, lte: to }); + // Every returned bucket_start falls inside the requested window. + for (const row of result?.rows ?? []) { + const t = new Date(row.measuredAt).getTime(); + expect(t).toBeGreaterThanOrEqual(from.getTime()); + expect(t).toBeLessThanOrEqual(to.getTime()); + } + // Tier width is 2 years (731 days) → still the MONTH tier for >730 d. + expect(result?.granularity).toBe("MONTH"); + }); + + // The web-chart "All" path passes `to ≈ now`; prove that case still bounds + // on both ends (no regression) — the historic fix must not perturb it. + it("bounds a current-window (to = now) read on both ends too", async () => { + findMany.mockResolvedValueOnce([bucket("2026-01-01T00:00:00.000Z")]); + const { from, to } = win(3650); + await readTieredRollupSeries({ userId: "u", type: "WEIGHT", from, to }); + expect(findMany.mock.calls[0][0].where.bucketStart).toEqual({ + gte: from, + lte: to, + }); + }); + it("reads the WEEK tier for a 1–2 year window", async () => { findMany.mockResolvedValueOnce([bucket("2025-08-04T00:00:00.000Z")]); const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 540, + ...win(540), }); expect(result?.granularity).toBe("WEEK"); expect(findMany.mock.calls[0][0].where.granularity).toBe("WEEK"); @@ -351,7 +409,7 @@ describe("readTieredRollupSeries", () => { const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 3650, + ...win(3650), }); expect(result?.granularity).toBe("DAY"); expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); @@ -364,7 +422,7 @@ describe("readTieredRollupSeries", () => { const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 3650, + ...win(3650), }); expect(result).toBeNull(); // MONTH, WEEK, DAY all probed (never coarser than the target). @@ -384,7 +442,7 @@ describe("readTieredRollupSeries", () => { const result = await readTieredRollupSeries({ userId: "u", type: "ACTIVITY_STEPS", - windowDays: 3650, + ...win(3650), }); expect(result?.rows[0].value).toBe(240_000); expect(result?.rows[0].minValue).toBeUndefined(); @@ -403,7 +461,7 @@ describe("readTieredRollupSeries", () => { const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 3650, + ...win(3650), }); expect(result?.rows[0].value).toBe(81.5); expect(result?.rows[0].minValue).toBe(78); diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 46eb1235d..779adf443 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -164,6 +164,10 @@ export async function readBestGranularityRollups( type, floor.granularity, since, + // Trailing-window semantics: no upper bound. This router serves the + // "last N days to now" probes (summaries-slice / health-score); the + // requested-window bounding lives on `readTieredRollupSeries`. + null, priority, ); if (rows && rows.length > 0) { @@ -229,20 +233,27 @@ async function readGranularity( userId: string, type: MeasurementType, granularity: RollupGranularity, - since: Date, + from: Date, + to: Date | null, userPriorityJson: unknown, ): Promise { // Bounded `findMany`: `(userId, type, granularity, bucketStart, source)` // is the composite primary key so the planner picks the index path - // every time. `bucketStart >= since` is the same shape - // `readRollupBuckets` uses; we don't carry an upper bound because - // the helpers are always reading "trailing window to now". + // every time. When `to` is supplied the `bucketStart` filter is bounded + // on BOTH ends (`gte from` AND `lte to`) so a caller asking for an + // arbitrary historic window — `[2020-01-01, 2022-01-01]` — reads the + // buckets INSIDE that window rather than the trailing "to now" slice. + // This mirrors the DAY-tier `readRollup` in `daily-series-read.ts` + // (`bucketStart: { gte: from, lte: to }`) so the tiered read reproduces + // the same window contract the live-SQL fallback bounds on both ends. + // `to === null` keeps the legacy trailing-window semantics for the + // `readBestGranularityRollups` router. const rows = await prisma.measurementRollup.findMany({ where: { userId, type, granularity, - bucketStart: { gte: since }, + bucketStart: to === null ? { gte: from } : { gte: from, lte: to }, }, orderBy: { bucketStart: "asc" }, select: { @@ -346,20 +357,32 @@ const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"]; * (≤ ~104 weeks for the WEEK tier, ≤ ~12 months/year for MONTH). If a * future tier ever risks an unbounded count it must step coarser rather * than truncate; no silent cap lives on this path. + * + * v1.26.0 SEAM-N2 — the reader is bounded to the REQUESTED `[from, to]` + * window on BOTH ends, not a trailing "now − windowDays … now" slice. + * The caller passes arbitrary ISO instants (a historic "All" range whose + * `to` need not be ≈ now), so anchoring on `Date.now()` returned the wrong + * buckets entirely. The tier SELECTION still keys off the window WIDTH + * (`windowDays`, derived from the span) — only the window the tier READS + * changed. This matches the DAY-tier `readRollup` + the live-SQL + * `readLiveDaily` fallback, both of which bound on `[from, to]`. */ export async function readTieredRollupSeries(opts: { userId: string; type: MeasurementType; - windowDays: number; + from: Date; + to: Date; priorityJson?: unknown; }): Promise<{ granularity: RollupGranularity; rows: TieredSeriesRow[]; } | null> { - const { userId, type, windowDays, priorityJson } = opts; + const { userId, type, from, to, priorityJson } = opts; + // Tier selection keys off the window WIDTH; derive it the same way the + // caller (`daily-series-read`) does so the chosen tier is identical. + const windowDays = Math.ceil((to.getTime() - from.getTime()) / 86_400_000); if (!Number.isFinite(windowDays) || windowDays <= 0) return null; - const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); const priority = priorityJson !== undefined ? priorityJson @@ -375,7 +398,8 @@ export async function readTieredRollupSeries(opts: { userId, type, granularity, - since, + from, + to, priority, ); if (rows && rows.length > 0) {