mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(insights): batch the derived-metric reads behind one route
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<T> 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.
This commit is contained in:
1 parent
3f8c8e750c
commit
b92d5cec82
8 files changed
+561
-16
No files matched your search
@@ -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 `<DERIVED_METRIC_ID>` or
|
||||
`<DERIVED_METRIC_ID>:<MeasurementType>` (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 `<DERIVED_METRIC_ID>` or
|
||||
`<DERIVED_METRIC_ID>:<MeasurementType>` (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 (`<metric>` or `<metric>:<type>`). Each value is the same flat
|
||||
`Derived<T>` 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:
|
||||
|
||||
@@ -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<typeof import("@/lib/insights/derived")>();
|
||||
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<Response>;
|
||||
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<string, { metric: string; status: string }> };
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* v1.10.0 — batched derived-wellness-metric route.
|
||||
*
|
||||
* `GET /api/insights/derived/batch?metrics=<id,id,…>[&types=<type,…>]`
|
||||
* resolves several derived metrics in ONE request and returns a map keyed
|
||||
* by the per-request key (`<metric>` or `<metric>:<type>` 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<string, BatchItem>();
|
||||
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<string, (typeof results)[number]["payload"]> = {};
|
||||
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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -88,3 +88,87 @@ export function useDerivedMetric<T>(
|
||||
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<string, DerivedMetricResponse<unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = <T>(
|
||||
token: DerivedBatchToken,
|
||||
) => DerivedMetricResponse<T> | 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<T>` 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<DerivedBatchResponse> => {
|
||||
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<T>(token: DerivedBatchToken): DerivedMetricResponse<T> | null {
|
||||
const entry = query.data?.metrics[tokenString(token)];
|
||||
return (entry as DerivedMetricResponse<T> | undefined) ?? null;
|
||||
}
|
||||
|
||||
return { ...query, read };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1406,6 +1406,36 @@ const derivedMetricResponse = z
|
||||
"Flat `Derived<T>` 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 `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (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 (`<metric>` or `<metric>:<type>`). Each value is the same flat `Derived<T>` 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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user