From b92d5cec820ae57358f6722e48948026d4bdbd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 00:00:03 +0200 Subject: [PATCH] feat(insights): batch the derived-metric reads behind one route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Insights cold mount fired 14+ independent GET /api/insights/derived requests — Readiness, Sleep and the vitals baselines are compute-on-visit, and all of them share one Prisma pool. That is the pool-starvation class that surfaces as the app hanging then recovering. Add GET /api/insights/derived/batch?metrics=a,b,c[:type] that resolves the requested metrics server-side under a bounded p-limit(4) with the profile loaded once via the shared loadBaselineProfile, returning a map of the same flat Derived envelope keyed by the request token. Add the useDerivedBatch hook + the insightsDerivedBatch query-key factory entry; the single-metric route + hook stay for the per-score detail pages. The single route now also adopts loadBaselineProfile instead of re-inlining the profile read. Honors the existing 8s ceiling, retry:0, replace-with-fallback, and warms nothing on visit. Register the new route in the OpenAPI contract. --- docs/api/openapi.yaml | 69 +++++++ .../derived/batch/__tests__/route.test.ts | 146 ++++++++++++++ src/app/api/insights/derived/batch/route.ts | 190 ++++++++++++++++++ src/app/api/insights/derived/route.ts | 22 +- .../insights/derived/use-derived-metric.ts | 84 ++++++++ src/lib/insights/derived/index.ts | 1 + src/lib/openapi/routes.ts | 55 +++++ src/lib/query-keys.ts | 10 + 8 files changed, 561 insertions(+), 16 deletions(-) create mode 100644 src/app/api/insights/derived/batch/__tests__/route.test.ts create mode 100644 src/app/api/insights/derived/batch/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9fbc47346..5d64a1842 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2484,6 +2484,40 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/insights/derived/batch: + get: + tags: + - Insights + summary: Derived wellness metrics (batched compute-once) + description: v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a + `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the + profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out + of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The + single-metric route stays for the per-score detail pages. Auth via cookie or Bearer. + parameters: + - in: query + name: metrics + schema: + type: string + minLength: 1 + maxLength: 1024 + description: Comma-separated derived-metric tokens. Each is a `` or + `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id + 422s; at most 24 tokens; duplicates collapse. + required: true + description: Comma-separated derived-metric tokens. Each is a `` or + `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; + at most 24 tokens; duplicates collapse. + responses: + "200": + description: The map of derived-metric values, keyed by token. + content: + application/json: + schema: + $ref: "#/components/schemas/DerivedBatchResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/correlations: get: tags: @@ -7720,6 +7754,41 @@ components: - windowDays - computedAt additionalProperties: false + DerivedBatchResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DerivedBatchResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DerivedBatchResponse: + type: object + properties: + metrics: + type: object + propertyNames: + type: string + additionalProperties: + $ref: "#/components/schemas/DerivedMetricResponse" + description: Map keyed by the per-request token (`` or `:`). Each value is the same flat + `Derived` envelope the single-metric route returns, so a client decodes one shape and reads back exactly + the tokens it asked for. + required: + - metrics + additionalProperties: false + description: Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the + derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma + pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table. CorrelationDiscoveryResponseEnvelope: type: object properties: diff --git a/src/app/api/insights/derived/batch/__tests__/route.test.ts b/src/app/api/insights/derived/batch/__tests__/route.test.ts new file mode 100644 index 000000000..58992b16f --- /dev/null +++ b/src/app/api/insights/derived/batch/__tests__/route.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + user: { + findUnique: vi.fn().mockResolvedValue({ + dateOfBirth: new Date("1986-01-01"), + gender: "MALE", + heightCm: 180, + }), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the compute layer; keep the real registry + loadBaselineProfile. +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + computeDerivedMetric: vi.fn(async (args: { metric: string }) => ({ + status: "ok", + value: { metric: args.metric, n: 1 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 100, band: "high" }, + provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" }, + })), + }; +}); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { computeDerivedMetric } from "@/lib/insights/derived"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(metrics?: string): NextRequest { + const url = new URL("http://localhost/api/insights/derived/batch"); + if (metrics !== undefined) url.searchParams.set("metrics", metrics); + return new NextRequest(url); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: new Date("1986-01-01"), + gender: "MALE", + heightCm: 180, + } as never); +}); + +describe("GET /api/insights/derived/batch", () => { + it("resolves several metrics into one keyed map", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet( + makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { metrics: Record }; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(Object.keys(body.data.metrics).sort()).toEqual([ + "BMI", + "READINESS", + "VITALS_BASELINE:WEIGHT", + ]); + expect(body.data.metrics["VITALS_BASELINE:WEIGHT"].status).toBe("ok"); + expect(body.data.metrics.READINESS.metric).toBe("READINESS"); + }); + + it("loads the profile once regardless of metric count", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("READINESS,BMI,FITNESS_AGE,HRV_BALANCE")); + // One User read for the whole batch — the pool-contention mitigation. + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); + expect(computeDerivedMetric).toHaveBeenCalledTimes(4); + }); + + it("narrows userId from the session into every compute call", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("READINESS")); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", metric: "READINESS" }), + ); + }); + + it("collapses a duplicate token so it cannot inflate the fan-out", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("BMI,BMI,BMI")); + expect(computeDerivedMetric).toHaveBeenCalledTimes(1); + }); + + it("422s when any token is an unknown metric id", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("READINESS,NOT_A_METRIC")); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("422s on a missing metrics param", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("422s on an empty metrics list", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq(" , , ")); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq("READINESS")); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts new file mode 100644 index 000000000..ea0c8e22d --- /dev/null +++ b/src/app/api/insights/derived/batch/route.ts @@ -0,0 +1,190 @@ +/** + * v1.10.0 — batched derived-wellness-metric route. + * + * `GET /api/insights/derived/batch?metrics=[&types=]` + * resolves several derived metrics in ONE request and returns a map keyed + * by the per-request key (`` or `:` for a + * VITALS_BASELINE sub-target). It exists to collapse the Insights cold + * mount's 14+ independent `GET /api/insights/derived` requests — each + * sharing one Prisma pool — into a single request that fans out + * server-side under a bounded `p-limit`, the exact pool-starvation class + * that bit v1.4.49.1 / v1.4.40 (the "app hangs then recovers" symptom). + * + * Honors the same contract as the single route: closed registry enum + * (unknown id → 422), cookie OR Bearer auth, `userId` narrowed from the + * session (never a query field), pure compute over the rollup tier (no + * provider call, no cache table). The profile is loaded ONCE via the + * shared `loadBaselineProfile` and threaded into every compute, so the + * batch never re-reads the `User` row per metric. + * + * The single-metric route stays for the anatomy detail pages (one metric + * per page); this route backs the dashboard's one-shot grid read. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import pLimit from "p-limit"; +import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; +import { + computeDerivedMetric, + loadBaselineProfile, + isDerivedMetricId, + type DerivedMetricId, +} from "@/lib/insights/derived"; + +export const dynamic = "force-dynamic"; + +// Bounded internal fan-out — matches the W-POOL `p-limit(4)` discipline +// v1.4.40 applied to the per-type analytics walk so a batch of 14 metrics +// never floods the shared Prisma pool. +const BATCH_CONCURRENCY = 4; +// Defensive ceiling — the dashboard requests ~11 metrics + ~6 baseline +// sub-targets; cap the list so a crafted query can't fan out unbounded. +const MAX_METRICS = 24; + +/** + * A single batch request item: a registered metric id with an optional + * VITALS_BASELINE sub-target. The wire form is `metric` or `metric:type` + * inside the `metrics` CSV; an entry's `type` is parsed off the colon. + */ +interface BatchItem { + metric: DerivedMetricId; + type: string | null; + /** The map key the response is keyed by (mirrors the request token). */ + key: string; +} + +const batchQuerySchema = z.object({ + metrics: z + .string() + .min(1) + .max(1024) + .describe("CSV of derived-metric tokens (`metric` or `metric:type`)."), +}); + +/** + * Parse the `metrics` CSV into validated items. An entry `metric:type` + * sub-targets a VITALS_BASELINE vital; a bare `metric` carries no type. + * Unknown ids are returned as `invalid` so the route 422s the whole + * request rather than silently dropping a metric (the closed-enum rule). + * Duplicate keys collapse to one item (last write wins) so a repeated + * token can't inflate the fan-out. + */ +function parseBatchItems(csv: string): { + items: BatchItem[]; + invalid: string[]; +} { + const seen = new Map(); + const invalid: string[] = []; + for (const raw of csv.split(",")) { + const token = raw.trim(); + if (token === "") continue; + const colon = token.indexOf(":"); + const metric = colon === -1 ? token : token.slice(0, colon); + const type = colon === -1 ? null : token.slice(colon + 1) || null; + if (!isDerivedMetricId(metric)) { + invalid.push(metric); + continue; + } + const key = type ? `${metric}:${type}` : metric; + seen.set(key, { metric, type, key }); + } + return { items: [...seen.values()], invalid }; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); + + const parsed = batchQuerySchema.safeParse({ + metrics: request.nextUrl.searchParams.get("metrics"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.derived.batch.invalid-query" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { items, invalid } = parseBatchItems(parsed.data.metrics); + if (invalid.length > 0) { + // Mirror the single route's closed-enum rule — an unknown id is a + // contract violation, not a silent drop. The id name is the caller's + // own input, never user health data, so it is safe to echo. + annotate({ + action: { name: "insights.derived.batch.invalid-metric" }, + meta: { invalid_count: invalid.length }, + }); + return apiError( + `Unknown derived metric id(s): ${invalid.join(", ")}`, + 422, + ); + } + if (items.length === 0 || items.length > MAX_METRICS) { + annotate({ + action: { name: "insights.derived.batch.invalid-count" }, + meta: { requested: items.length, max: MAX_METRICS }, + }); + return apiError( + items.length === 0 + ? "metrics must name at least one registered derived metric" + : `metrics must name at most ${MAX_METRICS} entries`, + 422, + ); + } + + // Profile read ONCE — the pool-contention mitigation; the same shared + // loader the nightly score jobs use, so the batch never re-reads the + // User row per metric. + const profile = await loadBaselineProfile(prisma, user.id); + + // Fan out under a bounded limiter so a 17-item dashboard read resolves + // four computes at a time against the shared pool, not seventeen. + const limit = pLimit(BATCH_CONCURRENCY); + const now = new Date(); + const results = await Promise.all( + items.map((item) => + limit(async () => { + const derived = await computeDerivedMetric({ + metric: item.metric, + userId: user.id, + profile, + type: item.type, + now, + }); + return { + key: item.key, + payload: { + metric: item.metric, + status: derived.status, + value: derived.status === "ok" ? derived.value : null, + coverage: derived.coverage, + confidence: derived.status === "ok" ? derived.confidence : null, + provenance: derived.provenance, + reason: + derived.status === "insufficient" ? derived.reason : null, + }, + }; + }), + ), + ); + + const map: Record = {}; + for (const r of results) map[r.key] = r.payload; + + annotate({ + action: { name: "insights.derived.batch" }, + meta: { + requested: items.length, + ok: results.filter((r) => r.payload.status === "ok").length, + }, + }); + + // `metrics` is a map keyed by the per-request token so the client reads + // back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`). + return apiSuccess({ metrics: map }); +}); diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 65356e615..c14536150 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -25,9 +25,9 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; -import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { computeDerivedMetric, + loadBaselineProfile, DERIVED_METRIC_IDS, type DerivedMetricId, } from "@/lib/insights/derived"; @@ -63,25 +63,15 @@ export const GET = apiHandler(async (request: NextRequest) => { } const metric = parsed.data.metric as DerivedMetricId; - // Profile read once, passed into the pure compute function (never - // re-fetched per metric — the pool-contention mitigation). - const profile = await prisma.user.findUnique({ - where: { id: user.id }, - select: { dateOfBirth: true, gender: true, heightCm: true }, - }); - const sex = - profile?.gender === "MALE" || profile?.gender === "FEMALE" - ? profile.gender - : null; + // Profile read once via the shared loader (the same one the batch route + // and the nightly score jobs use), passed into the pure compute function + // — never re-fetched per metric. + const profile = await loadBaselineProfile(prisma, user.id); const derived = await computeDerivedMetric({ metric, userId: user.id, - profile: { - ageYears: getAgeFromDateOfBirth(profile?.dateOfBirth ?? null), - sex, - heightCm: profile?.heightCm ?? null, - }, + profile, type: parsed.data.type ?? null, }); diff --git a/src/components/insights/derived/use-derived-metric.ts b/src/components/insights/derived/use-derived-metric.ts index 9d686b9e2..555af48dc 100644 --- a/src/components/insights/derived/use-derived-metric.ts +++ b/src/components/insights/derived/use-derived-metric.ts @@ -88,3 +88,87 @@ export function useDerivedMetric( retry: 0, }); } + +/** + * A batch request token: a metric id with an optional VITALS_BASELINE + * sub-target. Wire form is `metric` or `metric:type` inside the CSV. + */ +export interface DerivedBatchToken { + metric: string; + type?: string | null; +} + +/** Build the `metric` / `metric:type` wire token for one batch item. */ +function tokenString(token: DerivedBatchToken): string { + return token.type ? `${token.metric}:${token.type}` : token.metric; +} + +/** The batch route's `data` shape — a map keyed by the per-request token. */ +export interface DerivedBatchResponse { + metrics: Record>; +} + +/** + * The typed `read` selector `useDerivedBatch` returns — a tile narrows its + * own metric value out of the resolved map exactly as it did per-metric. + */ +export type DerivedBatchRead = ( + token: DerivedBatchToken, +) => DerivedMetricResponse | null; + +/** + * v1.10.0 — ONE batched read for a set of derived metrics, the dashboard's + * cold-mount fan-out fix. Replaces N independent `useDerivedMetric` queries + * (each a separate request sharing the Prisma pool — the v1.9.1 "hangs then + * recovers" symptom) with a single `GET /api/insights/derived/batch`. The + * server fans out under a bounded limiter and loads the profile once. + * + * Returns a typed `read(token)` selector so a tile pulls its own + * `DerivedMetricResponse` out of the map with the same narrowing it had + * per-metric. Same 8 s ceiling + `retry:0` + 60 s `staleTime` as the single + * hook; these reads never warm/generate on visit. + */ +export function useDerivedBatch( + tokens: DerivedBatchToken[], + options: { enabled?: boolean } = {}, +) { + const { enabled = true } = options; + const { isAuthenticated } = useAuth(); + const wireTokens = tokens.map(tokenString); + const gated = enabled && isAuthenticated && wireTokens.length > 0; + + const query = useQuery({ + queryKey: queryKeys.insightsDerivedBatch(wireTokens), + queryFn: async (): Promise => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + DERIVED_TIMEOUT_MS, + ); + try { + const params = new URLSearchParams({ metrics: wireTokens.join(",") }); + const res = await fetch( + `/api/insights/derived/batch?${params.toString()}`, + { signal: controller.signal }, + ); + if (!res.ok) { + throw new Error(`derived batch request failed (${res.status})`); + } + return (await res.json()).data as DerivedBatchResponse; + } finally { + clearTimeout(timeoutHandle); + } + }, + enabled: gated, + staleTime: 60_000, + retry: 0, + }); + + /** Pull one metric's value out of the resolved map, narrowed to `T`. */ + function read(token: DerivedBatchToken): DerivedMetricResponse | null { + const entry = query.data?.metrics[tokenString(token)]; + return (entry as DerivedMetricResponse | undefined) ?? null; + } + + return { ...query, read }; +} diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index 30807e1e4..658f7c972 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -55,6 +55,7 @@ export type { NormRange, NormSex } from "./norms"; // ── server-only compute engines (do NOT value-import from a client component) ── export { computeVitalsBaseline, + loadBaselineProfile, buildBaselineBand, median, medianAbsoluteDeviation, diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 49574c5d2..ed4a8b3de 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1406,6 +1406,36 @@ const derivedMetricResponse = z "Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.", }); +// v1.10.0 — batched derived-metric query. The `metrics` CSV carries one +// or more `metric` / `metric:type` tokens; the route fans out server-side +// under a bounded limiter with the profile loaded once, collapsing the +// dashboard's cold-mount fan-out of N single-metric requests into one. +const derivedBatchQuery = z + .object({ + metrics: z + .string() + .min(1) + .max(1024) + .describe( + "Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; at most 24 tokens; duplicates collapse.", + ), + }) + .meta({ id: "DerivedBatchQuery" }); + +const derivedBatchResponse = z + .object({ + metrics: z + .record(z.string(), derivedMetricResponse) + .describe( + "Map keyed by the per-request token (`` or `:`). Each value is the same flat `Derived` envelope the single-metric route returns, so a client decodes one shape and reads back exactly the tokens it asked for.", + ), + }) + .meta({ + id: "DerivedBatchResponse", + description: + "Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table.", + }); + // v1.10.0 — FDR-controlled correlation discovery result. One discovered, // statistically-defensible behaviour → next-day-outcome pair. const discoveredCorrelation = z @@ -3190,6 +3220,31 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/insights/derived/batch": { + get: { + tags: ["Insights"], + summary: "Derived wellness metrics (batched compute-once)", + description: + "v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.", + requestParams: { + query: derivedBatchQuery, + }, + responses: { + "200": { + description: "The map of derived-metric values, keyed by token.", + content: { + "application/json": { + schema: dataEnvelope( + derivedBatchResponse, + "DerivedBatchResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/correlations": { get: { tags: ["Insights"], diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index b6c8af25a..2c61d640e 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -158,6 +158,16 @@ export const queryKeys = { */ insightsDerived: (metric: string, type?: string | null) => ["insights", "derived", metric, type ?? null] as const, + /** + * v1.10.0 — batched derived-metric read (`/api/insights/derived/batch`). + * One request resolves the whole dashboard grid server-side instead of N + * concurrent single-metric reads sharing the Prisma pool. Keyed by the + * sorted token list so the same grid always hits one cache entry; sits + * under the `["insights", "derived"]` prefix so a measurement-write + * invalidation fan-out reaches it exactly like the single-metric reads. + */ + insightsDerivedBatch: (tokens: readonly string[]) => + ["insights", "derived", "batch", [...tokens].sort().join(",")] as const, /** * v1.10.0 — FDR-controlled correlation discovery * (`/api/insights/correlations`). Read-only descriptive surface; the