Merge remote-tracking branch 'origin/main' into wip/v140-I

This commit is contained in:
Marc-André Bombeck committed 2026-10-03 22:00:18 +02:00
commit 9c2b57df33
10 files changed
+566 -57

No files matched your search

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