mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(insights,cache): surface revalidation to clients and evict on interactive writes
Stale-served insights responses now carry a revalidating flag; the advisor and comprehensive queries poll on a bounded 25 s cadence until the background warm lands, so an open session converges instead of pinning yesterday's briefing for an hour. Interactive measurement writes (single create, edit, delete, bulk-delete, restore) hard-evict the analytics cache so a manually entered value is reflected on the next read; background batch sync keeps the mark-stale path. The visit-triggered force warm collapses to one job per user instead of one per locale, and the umami script proxy failure now annotates a named action with the failure reason instead of a bare flag.
This commit is contained in:
1 parent
78fe82e348
commit
03453aaed2
23 files changed
+461
-55
No files matched your search
@@ -11769,6 +11769,11 @@ components:
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
revalidating:
|
||||
description: True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs
|
||||
in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the
|
||||
fresh body.
|
||||
type: boolean
|
||||
required:
|
||||
- summary
|
||||
- recommendations
|
||||
|
||||
@@ -51,7 +51,7 @@ export const GET = apiHandler(async () => {
|
||||
// the former rate-limit bucket was an unconditional Postgres UPSERT
|
||||
// per request, i.e. the limiter WAS the per-request DB load it claimed
|
||||
// to prevent. The route stays authenticated; the only uncached work is
|
||||
// one `appSettings` read per 60 s process-wide.
|
||||
// one `appSettings` read per 10 min (the bucket's TTL) process-wide.
|
||||
|
||||
// Cache the global app-settings shape on a singleton key (per blueprint §3).
|
||||
// `isAdmin` lives outside the cache because it varies per request. The
|
||||
|
||||
@@ -91,7 +91,7 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -438,3 +438,64 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
expect(body.data.weightBpCorrelation?.n).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.16.7 — the route serves a marked-stale cache entry immediately
|
||||
// (stale-while-revalidate) and must mark that body `revalidating: true`
|
||||
// so the client can poll until the background rebuild lands. A fresh
|
||||
// (cold or warm) read carries `revalidating: false`.
|
||||
describe("GET /api/insights/comprehensive — stale-while-revalidate marker", () => {
|
||||
const EMPTY_AGGREGATE = {
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
};
|
||||
|
||||
it("marks a cold (inline-computed) read revalidating: false", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(EMPTY_AGGREGATE);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { revalidating: boolean } };
|
||||
expect(body.data.revalidating).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a stale-served read revalidating: true and a converged read false again", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(EMPTY_AGGREGATE);
|
||||
|
||||
// Prime the cache entry (cold compute).
|
||||
await callGet(makeReq());
|
||||
|
||||
// A measurement sync marks the bucket stale (the invalidate helper's
|
||||
// default posture) — the next read serves the prior body + the flag.
|
||||
caches.analytics.markStaleByPrefix(`${SESSION_OK.user.id}|`);
|
||||
const staleRes = await callGet(makeReq());
|
||||
expect(staleRes.status).toBe(200);
|
||||
const staleBody = (await staleRes.json()) as {
|
||||
data: { revalidating: boolean };
|
||||
};
|
||||
expect(staleBody.data.revalidating).toBe(true);
|
||||
|
||||
// The stale read kicked off a background rebuild; once it settles
|
||||
// (the cache entry is fresh again) the next read is a plain hit and
|
||||
// the flag drops so the poll stops.
|
||||
await vi.waitFor(() => {
|
||||
const entry = caches.analytics.getAllowStale(
|
||||
`${SESSION_OK.user.id}|comprehensive`,
|
||||
);
|
||||
expect(entry?.stale).toBe(false);
|
||||
});
|
||||
const freshRes = await callGet(makeReq());
|
||||
const freshBody = (await freshRes.json()) as {
|
||||
data: { revalidating: boolean };
|
||||
};
|
||||
expect(freshBody.data.revalidating).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,11 @@ import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
cachedSwrWithMeta,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import {
|
||||
ensureUserMoodRollupsFresh,
|
||||
@@ -63,7 +67,13 @@ export const GET = apiHandler(async () => {
|
||||
// rebuild on the very next mount, all day). The truly-cold first
|
||||
// read of the day still computes inline — there is no prior body to
|
||||
// serve — but it no longer recurs per visit.
|
||||
const body = await cachedSwr(
|
||||
//
|
||||
// The `revalidating` flag marks a stale-served body honestly so the
|
||||
// client can poll (bounded) until the background rebuild lands —
|
||||
// mirroring the `/api/insights/generate` GET contract. It rides on
|
||||
// the response, never inside the cached body, so a warmed entry
|
||||
// doesn't freeze the flag.
|
||||
const { value: body, outcome } = await cachedSwrWithMeta(
|
||||
caches.analytics as ServerCache<Awaited<
|
||||
ReturnType<typeof buildComprehensiveResponse>
|
||||
>>,
|
||||
@@ -72,7 +82,7 @@ export const GET = apiHandler(async () => {
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess(body);
|
||||
return apiSuccess({ ...body, revalidating: outcome === "stale" });
|
||||
});
|
||||
|
||||
type AuthedUser = AuthContext["user"];
|
||||
|
||||
@@ -448,14 +448,16 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { insights: unknown; cached: boolean };
|
||||
data: { insights: unknown; cached: boolean; revalidating: boolean };
|
||||
};
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(body.data.insights).toEqual(cached);
|
||||
// No completion is ever run on the read path.
|
||||
expect(resolveProvider).not.toHaveBeenCalled();
|
||||
// A fresh cache (just now) does not trigger a warm.
|
||||
// A fresh cache (just now) does not trigger a warm — and the payload
|
||||
// says so, so the client never starts a convergence poll.
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
expect(body.data.revalidating).toBe(false);
|
||||
});
|
||||
|
||||
it("enqueues an out-of-band warm when the cache is stale and a provider exists", async () => {
|
||||
@@ -475,6 +477,10 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
locale: "en",
|
||||
});
|
||||
expect(resolveProvider).not.toHaveBeenCalled();
|
||||
// The stale serve is honest: `revalidating: true` rides on the
|
||||
// payload so the client polls (bounded) until the warm lands.
|
||||
const body = (await res.json()) as { data: { revalidating: boolean } };
|
||||
expect(body.data.revalidating).toBe(true);
|
||||
});
|
||||
|
||||
it("returns an empty payload (no warm) on a cold cache without a provider", async () => {
|
||||
@@ -490,10 +496,12 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { insights: unknown; cached: boolean };
|
||||
data: { insights: unknown; cached: boolean; revalidating: boolean };
|
||||
};
|
||||
expect(body.data.cached).toBe(false);
|
||||
expect(body.data.insights).toBeNull();
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
// No warm enqueued → no convergence poll for the client to run.
|
||||
expect(body.data.revalidating).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -313,6 +313,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
cached: true,
|
||||
cachedAt,
|
||||
legacyPayload,
|
||||
// Honest stale-serve marker: true while the out-of-band warm is
|
||||
// in flight, so the client can poll (bounded) until the fresh
|
||||
// briefing lands instead of sitting on the stale payload for the
|
||||
// rest of the session.
|
||||
revalidating,
|
||||
});
|
||||
} catch {
|
||||
// Invalid cache row — fall through to the empty payload below. The
|
||||
@@ -325,7 +330,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "insights.generate.read" },
|
||||
meta: { cached: false, revalidating },
|
||||
});
|
||||
return apiSuccess({ insights: null, cached: false, legacyPayload: false });
|
||||
return apiSuccess({
|
||||
insights: null,
|
||||
cached: false,
|
||||
legacyPayload: false,
|
||||
revalidating,
|
||||
});
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
@@ -148,8 +148,9 @@ export const PUT = apiHandler(
|
||||
});
|
||||
|
||||
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
|
||||
// caches so subsequent reads reflect the edited row.
|
||||
invalidateUserMeasurements(user.id);
|
||||
// caches so subsequent reads reflect the edited row. Interactive
|
||||
// edit — hard-evict so the SWR readers don't serve the pre-edit body.
|
||||
invalidateUserMeasurements(user.id, { evict: true });
|
||||
|
||||
// v1.5.0 — refresh the rollup row for the affected day. When the
|
||||
// measuredAt moved across day boundaries (or the row was re-typed)
|
||||
@@ -236,8 +237,10 @@ export const DELETE = apiHandler(
|
||||
});
|
||||
|
||||
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
|
||||
// caches so subsequent reads reflect the deletion.
|
||||
invalidateUserMeasurements(user.id);
|
||||
// caches so subsequent reads reflect the deletion. Interactive
|
||||
// delete — hard-evict so the SWR readers don't serve the pre-delete
|
||||
// body.
|
||||
invalidateUserMeasurements(user.id, { evict: true });
|
||||
|
||||
// v1.5.0 — refresh the rollup row for the affected day (the
|
||||
// recompute drops the row when the day's measurement count goes
|
||||
|
||||
@@ -125,7 +125,11 @@ describe("POST /api/measurements/bulk-delete", () => {
|
||||
});
|
||||
expect(call.data.deletedAt).toBeInstanceOf(Date);
|
||||
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
|
||||
// v1.16.7 — interactive write: hard-evict so the SWR readers
|
||||
// never serve the pre-write analytics body back to the user.
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
|
||||
evict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
|
||||
|
||||
@@ -105,8 +105,10 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
|
||||
if (count > 0) {
|
||||
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
|
||||
// caches so subsequent reads reflect the deletions.
|
||||
invalidateUserMeasurements(user.id);
|
||||
// caches so subsequent reads reflect the deletions. Interactive
|
||||
// multi-select delete from the management list — hard-evict so the
|
||||
// SWR readers don't serve the pre-delete body.
|
||||
invalidateUserMeasurements(user.id, { evict: true });
|
||||
|
||||
// Collapse the deleted rows to the unique `(type, day)` set BEFORE
|
||||
// recomputing so a 200-row delete spanning one day fires ~1 recompute
|
||||
|
||||
@@ -122,7 +122,11 @@ describe("POST /api/measurements/restore", () => {
|
||||
syncVersion: { increment: 1 },
|
||||
});
|
||||
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
|
||||
// v1.16.7 — interactive write: hard-evict so the SWR readers
|
||||
// never serve the pre-write analytics body back to the user.
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
|
||||
evict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
|
||||
|
||||
@@ -106,8 +106,9 @@ async function postRestore(request: NextRequest): Promise<Response> {
|
||||
|
||||
if (count > 0) {
|
||||
// Bust per-user analytics + achievements + workouts caches so
|
||||
// subsequent reads reflect the restored rows.
|
||||
invalidateUserMeasurements(user.id);
|
||||
// subsequent reads reflect the restored rows. Interactive undo —
|
||||
// hard-evict so the SWR readers don't serve the pre-restore body.
|
||||
invalidateUserMeasurements(user.id, { evict: true });
|
||||
|
||||
// Collapse the restored rows to the unique `(type, day)` set BEFORE
|
||||
// recomputing — mirrors the bulk-delete path. Best-effort: a
|
||||
|
||||
@@ -916,8 +916,10 @@ async function postMeasurement(request: NextRequest) {
|
||||
});
|
||||
|
||||
// v1.4.34 IW-G — flush every cache that reflects this user's
|
||||
// measurement set so the next read paints the new rows.
|
||||
invalidateUserMeasurements(user.id);
|
||||
// measurement set so the next read paints the new rows. Interactive
|
||||
// multi-entry create (combined BP + pulse form) — hard-evict so the
|
||||
// SWR readers don't serve the pre-write body back to the user.
|
||||
invalidateUserMeasurements(user.id, { evict: true });
|
||||
|
||||
// v1.5.0 — refresh the persistent rollup table for every distinct
|
||||
// (type, day) the batch touched so the next analytics / coach read
|
||||
@@ -1027,8 +1029,10 @@ async function postMeasurement(request: NextRequest) {
|
||||
});
|
||||
|
||||
// v1.4.34 IW-G — flush every cache that reflects this user's
|
||||
// measurement set so the next read paints the new row.
|
||||
invalidateUserMeasurements(user.id);
|
||||
// measurement set so the next read paints the new row. Interactive
|
||||
// single-entry create — hard-evict so the SWR readers don't serve the
|
||||
// pre-write body back to the user.
|
||||
invalidateUserMeasurements(user.id, { evict: true });
|
||||
|
||||
// v1.5.0 — refresh the persistent rollup row for the affected
|
||||
// (type, day) tuple. Runs inline so the next read of the
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Fail-soft contract for the Umami proxy route: an unreachable / refused
|
||||
* upstream degrades to the noop script (200, never a 500) AND stays
|
||||
* observable — the catch annotates a proper wide-event action
|
||||
* (`monitoring.umami_script.fetch_failed`) with a truncated reason so
|
||||
* dashboards can see the degraded state.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring-settings", () => ({
|
||||
getPublicMonitoringSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/safe-fetch", () => ({
|
||||
safeFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
|
||||
const callGet = GET as unknown as () => Promise<Response>;
|
||||
|
||||
const ENABLED_SETTINGS = {
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://stats.example.com/script.js",
|
||||
umamiWebsiteId: "site-1",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getPublicMonitoringSettings).mockResolvedValue(
|
||||
ENABLED_SETTINGS as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/monitoring/umami-script — fail-soft observability", () => {
|
||||
it("serves the noop script (200) and annotates the failure action when the upstream fetch throws", async () => {
|
||||
vi.mocked(safeFetch).mockRejectedValue(
|
||||
new Error("connect ETIMEDOUT 192.0.2.10:443"),
|
||||
);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toContain("umami disabled");
|
||||
|
||||
// The fail-soft must stay observable: a proper
|
||||
// `<surface>.<noun>.<verb>` action + a truncated reason, not a bare
|
||||
// meta flag.
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
action: { name: "monitoring.umami_script.fetch_failed" },
|
||||
meta: { reason: "connect ETIMEDOUT 192.0.2.10:443" },
|
||||
});
|
||||
});
|
||||
|
||||
it("truncates an oversized error message in the reason meta", async () => {
|
||||
vi.mocked(safeFetch).mockRejectedValue(new Error("x".repeat(500)));
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const failureCall = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
call[0]?.action?.name === "monitoring.umami_script.fetch_failed",
|
||||
);
|
||||
expect(failureCall).toBeTruthy();
|
||||
expect(
|
||||
(failureCall?.[0]?.meta as { reason: string }).reason,
|
||||
).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("serves the upstream script untouched on a healthy fetch (no failure action)", async () => {
|
||||
vi.mocked(safeFetch).mockResolvedValue(
|
||||
new Response("/* real script */", { status: 200 }) as never,
|
||||
);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("/* real script */");
|
||||
|
||||
const failureCall = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
call[0]?.action?.name === "monitoring.umami_script.fetch_failed",
|
||||
);
|
||||
expect(failureCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -43,8 +43,19 @@ export const GET = apiHandler(async () => {
|
||||
// the input-time `isPublicUrl` accept and the socket connect.
|
||||
{ requirePublicHost: true },
|
||||
);
|
||||
} catch {
|
||||
annotate({ meta: { umami_script_fetch_failed: true } });
|
||||
} catch (err) {
|
||||
// Fail-soft stays a 200, but the failure must stay observable:
|
||||
// a proper action name keeps the degraded state visible on the
|
||||
// wide-event dashboards instead of hiding behind a bare meta flag.
|
||||
annotate({
|
||||
action: { name: "monitoring.umami_script.fetch_failed" },
|
||||
meta: {
|
||||
reason:
|
||||
err instanceof Error
|
||||
? err.message.slice(0, 200)
|
||||
: String(err).slice(0, 200),
|
||||
},
|
||||
});
|
||||
return new NextResponse(NOOP_SCRIPT, {
|
||||
status: 200,
|
||||
headers: {
|
||||
|
||||
@@ -78,3 +78,36 @@ describe("fetchAdvisor abort timeout — v1.4.31", () => {
|
||||
expect(result).toBe("untouched");
|
||||
});
|
||||
});
|
||||
|
||||
// v1.16.7 — the read query polls (bounded) while the server reports a
|
||||
// stale-served briefing (`revalidating: true`) so a stale serve converges
|
||||
// in-session despite the 1 h staleTime + focus-refetch-off defaults.
|
||||
describe("nextAdvisorPollInterval — bounded revalidation poll", () => {
|
||||
it("returns the interval while revalidating is true and under the cap", async () => {
|
||||
const {
|
||||
nextAdvisorPollInterval,
|
||||
ADVISOR_REVALIDATE_POLL_MS,
|
||||
} = await import("../use-insights-advisor");
|
||||
expect(nextAdvisorPollInterval(true, 1)).toBe(ADVISOR_REVALIDATE_POLL_MS);
|
||||
expect(nextAdvisorPollInterval(true, 5)).toBe(ADVISOR_REVALIDATE_POLL_MS);
|
||||
});
|
||||
|
||||
it("stops once a response comes back with revalidating falsy", async () => {
|
||||
const { nextAdvisorPollInterval } = await import("../use-insights-advisor");
|
||||
expect(nextAdvisorPollInterval(false, 1)).toBe(false);
|
||||
expect(nextAdvisorPollInterval(undefined, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("stops at the attempt ceiling even while still revalidating", async () => {
|
||||
const {
|
||||
nextAdvisorPollInterval,
|
||||
ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS,
|
||||
} = await import("../use-insights-advisor");
|
||||
expect(
|
||||
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS),
|
||||
).toBe(false);
|
||||
expect(
|
||||
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS + 5),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,10 @@ import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useWorkouts } from "@/hooks/use-workouts";
|
||||
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import {
|
||||
nextAdvisorPollInterval,
|
||||
useInsightsAdvisorQuery,
|
||||
} from "@/components/insights/use-insights-advisor";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -41,6 +44,12 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
interface ComprehensivePayload {
|
||||
moodSummary: { count: number } | null;
|
||||
medications: Array<{ id: string }>;
|
||||
/**
|
||||
* v1.16.7 — true when the route served a stale cache body while a
|
||||
* background rebuild runs (stale-while-revalidate). The query polls
|
||||
* (bounded) while set so the fresh aggregate lands in-session.
|
||||
*/
|
||||
revalidating?: boolean;
|
||||
}
|
||||
|
||||
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
@@ -91,6 +100,14 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
return apiGet<ComprehensivePayload>("/api/insights/comprehensive");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
// v1.16.7 — converge a stale-served (SWR) body in-session: while the
|
||||
// route reports `revalidating: true`, poll on the same bounded
|
||||
// cadence the advisor query uses until a fresh body lands.
|
||||
refetchInterval: (query) =>
|
||||
nextAdvisorPollInterval(
|
||||
query.state.data?.revalidating,
|
||||
query.state.dataUpdateCount,
|
||||
),
|
||||
});
|
||||
|
||||
// v1.4.32 — workout-presence gate. The workouts pill gates on at
|
||||
|
||||
@@ -54,6 +54,13 @@ export interface InsightAdvisorPayload {
|
||||
* left null when the cached payload predates PROMPT_VERSION 4.20.1.
|
||||
*/
|
||||
trendAnnotations?: TrendAnnotations | null;
|
||||
/**
|
||||
* v1.16.7 — true when the GET served a stale / missing briefing AND
|
||||
* enqueued an out-of-band warm. The query polls (bounded) while this
|
||||
* is set so the fresh briefing reaches the open page in-session
|
||||
* instead of waiting for the next mount.
|
||||
*/
|
||||
revalidating?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +87,35 @@ const ADVISOR_TIMEOUT_MS = 8_000;
|
||||
*/
|
||||
const FORCE_ADVISOR_TIMEOUT_MS = 45_000;
|
||||
|
||||
/**
|
||||
* v1.16.7 — poll cadence + ceiling while the server reports
|
||||
* `revalidating: true` (stale briefing served, out-of-band warm in
|
||||
* flight). The query's 1 h `staleTime` plus the app-default
|
||||
* `refetchOnWindowFocus: false` means a stale-served briefing would
|
||||
* otherwise never refresh in-session. 25 s comfortably covers the warm
|
||||
* job's 45 s budget within two polls; the attempt ceiling stops a
|
||||
* persistently failing generation from polling an open page forever.
|
||||
* Same bounded-poll shape as `nextStatusPollInterval`
|
||||
* (`src/hooks/use-insight-status.ts`).
|
||||
*/
|
||||
export const ADVISOR_REVALIDATE_POLL_MS = 25_000;
|
||||
export const ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS = 10;
|
||||
|
||||
/**
|
||||
* Decide whether the advisor query schedules its next poll. Pure so the
|
||||
* ceiling + stop conditions are unit-testable: returns the interval
|
||||
* while the last payload carries `revalidating: true`, `false` once a
|
||||
* response comes back with the flag falsy OR the attempt cap is hit.
|
||||
*/
|
||||
export function nextAdvisorPollInterval(
|
||||
revalidating: boolean | undefined,
|
||||
dataUpdateCount: number,
|
||||
): number | false {
|
||||
if (!revalidating) return false;
|
||||
if (dataUpdateCount >= ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS) return false;
|
||||
return ADVISOR_REVALIDATE_POLL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.18 — the outcome of a force regenerate, so the UI can be HONEST:
|
||||
* only a `fresh` outcome should toast "refreshed". A `timeout` (slow gen
|
||||
@@ -244,6 +280,14 @@ export function useInsightsAdvisorQuery(
|
||||
// 24h cache window matches the server-side `insightsCachedAt` TTL.
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: false,
|
||||
// v1.16.7 — converge a stale-served briefing in-session: while the
|
||||
// last GET reported `revalidating: true` (warm enqueued), poll on a
|
||||
// bounded interval until a response comes back with the flag falsy.
|
||||
refetchInterval: (query) =>
|
||||
nextAdvisorPollInterval(
|
||||
query.state.data?.payload?.revalidating,
|
||||
query.state.dataUpdateCount,
|
||||
),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
|
||||
+38
@@ -117,6 +117,44 @@ describe("invalidateUserMeasurements", () => {
|
||||
// Mood-analytics not touched by a measurement write.
|
||||
expect(caches.moodAnalytics.get(USER_A)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks the analytics bucket stale (SWR-serveable) by default — background sync posture", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateUserMeasurements(USER_A);
|
||||
|
||||
// v1.16.7 — the default (batch sync) path marks stale: the entry is
|
||||
// gone for plain `get()` (asserted above) but stays serveable for
|
||||
// the `cachedSwr` readers, so a high-frequency sync never busts the
|
||||
// snapshot / comprehensive into an inline cold rebuild.
|
||||
expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toEqual({
|
||||
value: { a: 1 },
|
||||
stale: true,
|
||||
});
|
||||
expect(
|
||||
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
|
||||
).toEqual({ value: { snap: 1 }, stale: true });
|
||||
});
|
||||
|
||||
it("hard-evicts the analytics bucket with { evict: true } — interactive write posture", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateUserMeasurements(USER_A, { evict: true });
|
||||
|
||||
// v1.16.7 — an interactive single-entry write must NOT leave a
|
||||
// stale-serveable body behind: the SWR readers would hand the user
|
||||
// back the pre-write payload. The evict drops the entries entirely.
|
||||
expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toBeNull();
|
||||
expect(caches.analytics.getAllowStale(`${USER_A}|summaries`)).toBeNull();
|
||||
expect(
|
||||
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
|
||||
).toBeNull();
|
||||
|
||||
// User B stays warm; the non-analytics buckets evict as before.
|
||||
expect(
|
||||
caches.analytics.getAllowStale(`${USER_B}|default`),
|
||||
).not.toBeNull();
|
||||
expect(caches.achievements.get(USER_A)).toBeNull();
|
||||
expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateUserMood", () => {
|
||||
|
||||
Vendored
+29
-12
@@ -47,22 +47,39 @@ export function invalidateUserDashboardSnapshot(userId: string): void {
|
||||
* Covers: analytics aggregate, achievement progress, workouts cache.
|
||||
* Mood-analytics is invalidated separately via `invalidateUserMood`
|
||||
* because mood writes don't change measurement rows.
|
||||
*
|
||||
* v1.16.7 — `evict` splits the analytics bucket by write origin. The
|
||||
* `cachedSwr` readers (dashboard snapshot, comprehensive) serve a
|
||||
* marked-stale entry as-is, so a mark-stale right after an interactive
|
||||
* write would hand the user back the pre-write body. Interactive
|
||||
* single-entry routes (manual create / update / delete, bulk-delete,
|
||||
* restore) pass `{ evict: true }` so the user's own entry is reflected
|
||||
* on the very next read — matching the mood / medication hard-evict
|
||||
* posture. Background batch syncs (Apple Health, Withings, workouts)
|
||||
* keep the default mark-stale so a high-frequency sync never busts the
|
||||
* bucket into inline cold-rebuild storms.
|
||||
*/
|
||||
export function invalidateUserMeasurements(userId: string): void {
|
||||
export function invalidateUserMeasurements(
|
||||
userId: string,
|
||||
opts?: { evict?: boolean },
|
||||
): void {
|
||||
// The `${userId}|` prefix covers the slim / thick analytics cells, the
|
||||
// iOS summary cell, AND the v1.7.0 dashboard snapshot
|
||||
// (`${userId}|dashboard-snapshot`) in one pass.
|
||||
//
|
||||
// v1.12.7 — mark stale rather than hard-evict. Measurement writes are
|
||||
// the highest-frequency dirty signal (every iOS Apple-Health sync
|
||||
// posts a batch), and a hard evict busts the snapshot into a cold
|
||||
// rebuild on the next read all day long. Marking stale lets the
|
||||
// `cachedSwr` snapshot read serve the prior value immediately while a
|
||||
// single background recompute warms a fresh one. The slim / thick /
|
||||
// summary cells read via plain `cached` are unaffected: a marked-stale
|
||||
// entry has `expiresAt === now`, so their next read is a clean miss and
|
||||
// rebuilds fresh — identical to the old evict for those keys.
|
||||
caches.analytics.markStaleByPrefix(`${userId}|`);
|
||||
if (opts?.evict) {
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
} else {
|
||||
// v1.12.7 — mark stale rather than hard-evict. Measurement writes are
|
||||
// the highest-frequency dirty signal (every iOS Apple-Health sync
|
||||
// posts a batch), and a hard evict busts the snapshot into a cold
|
||||
// rebuild on the next read all day long. Marking stale lets the
|
||||
// `cachedSwr` snapshot read serve the prior value immediately while a
|
||||
// single background recompute warms a fresh one. The slim / thick /
|
||||
// summary cells read via plain `cached` are unaffected: a marked-stale
|
||||
// entry has `expiresAt === now`, so their next read is a clean miss and
|
||||
// rebuilds fresh — identical to the old evict for those keys.
|
||||
caches.analytics.markStaleByPrefix(`${userId}|`);
|
||||
}
|
||||
caches.achievements.deleteByPrefix(userId);
|
||||
caches.workouts.deleteByPrefix(`${userId}|`);
|
||||
// v1.4.36 W1 — measurement writes change the per-target consistency
|
||||
|
||||
Vendored
+24
-1
@@ -522,6 +522,29 @@ export async function cachedSwr<T>(
|
||||
annotateFn?: (fields: { meta: Record<string, unknown> }) => void,
|
||||
ttlMsOverride?: number,
|
||||
): Promise<T> {
|
||||
const { value } = await cachedSwrWithMeta(
|
||||
cache,
|
||||
key,
|
||||
builder,
|
||||
annotateFn,
|
||||
ttlMsOverride,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.7 — `cachedSwr` variant that also surfaces the read outcome so
|
||||
* a route can mark a stale-served response (`outcome === "stale"`) as
|
||||
* `revalidating` for the client. Non-breaking addition: `cachedSwr`
|
||||
* keeps its value-only shape and delegates here.
|
||||
*/
|
||||
export async function cachedSwrWithMeta<T>(
|
||||
cache: ServerCache<T>,
|
||||
key: string,
|
||||
builder: () => Promise<T>,
|
||||
annotateFn?: (fields: { meta: Record<string, unknown> }) => void,
|
||||
ttlMsOverride?: number,
|
||||
): Promise<{ value: T; outcome: "hit" | "stale" | "miss" | "stampede" }> {
|
||||
const { value, outcome } = await cache.wrapSwr(key, builder, ttlMsOverride);
|
||||
if (annotateFn) {
|
||||
const name = cache.stats().name;
|
||||
@@ -532,7 +555,7 @@ export async function cachedSwr<T>(
|
||||
},
|
||||
});
|
||||
}
|
||||
return value;
|
||||
return { value, outcome };
|
||||
}
|
||||
|
||||
/** Test helper — reset every cache in the registry. */
|
||||
|
||||
@@ -35,12 +35,17 @@ export interface InsightPregeneratePayload {
|
||||
|
||||
/**
|
||||
* Fire-and-forget enqueue used by the on-demand warm route. A
|
||||
* `singletonKey` per `(user, locale)` collapses repeated requests within
|
||||
* a short window into one queued job, so a client that taps the button
|
||||
* twice (or the auto-trigger races a manual tap) can't double-warm. No-ops
|
||||
* cleanly when the global boss instance is not available (e.g. a web
|
||||
* process without an embedded worker) — the nightly cron remains the
|
||||
* catch-net.
|
||||
* `singletonKey` per user collapses repeated requests within a short
|
||||
* window into one queued job, so a client that taps the button twice
|
||||
* (or the auto-trigger races a manual tap) can't double-warm. The key
|
||||
* is deliberately locale-free: `forceWarmUser` warms BOTH locale
|
||||
* families per job (v1.16.7), so a per-(user, locale) key would let a
|
||||
* German web session and an English Accept-Language client enqueue two
|
||||
* near-identical full warms back to back — the second one force-
|
||||
* regenerating the comprehensive briefing and re-evicting the
|
||||
* per-status rows the first job just wrote. No-ops cleanly when the
|
||||
* global boss instance is not available (e.g. a web process without an
|
||||
* embedded worker) — the nightly cron remains the catch-net.
|
||||
*/
|
||||
export async function enqueueForceWarm(payload: {
|
||||
userId: string;
|
||||
@@ -63,7 +68,7 @@ export async function enqueueForceWarm(payload: {
|
||||
locale: payload.locale,
|
||||
} satisfies InsightPregeneratePayload,
|
||||
{
|
||||
singletonKey: `force:${payload.userId}:${payload.locale}`,
|
||||
singletonKey: `force:${payload.userId}`,
|
||||
// De-dupe within a 2-minute window so a double tap or an
|
||||
// auto-trigger racing a manual tap enqueues one warm, not two.
|
||||
singletonSeconds: 120,
|
||||
|
||||
@@ -691,15 +691,15 @@ export async function forceWarmUser(
|
||||
// outcome that has nothing to warm is a missing provider, and the
|
||||
// generators detect that themselves at near-zero cost.
|
||||
if (flags.insightStatus) {
|
||||
// Mirror the nightly loop's force/refill split: only a `generated` /
|
||||
// `cached` comprehensive ran `evictPerStatusInsightCache`, so only
|
||||
// then must every card re-generate. On a failed / timed-out /
|
||||
// skipped comprehensive the rows survived — refill-only (`force:
|
||||
// false`) lets a card already generated today short-circuit to its
|
||||
// cache, which is what keeps the second locale family cheap.
|
||||
const evicted =
|
||||
result.comprehensive === "generated" ||
|
||||
result.comprehensive === "cached";
|
||||
// Mirror the nightly loop's force/refill split: only a `generated`
|
||||
// comprehensive ran `evictPerStatusInsightCache`, so only then must
|
||||
// every card re-generate. (The forced generation above never returns
|
||||
// `cached` — `force: true` skips the 24 h cache-hit branch.) On a
|
||||
// failed / timed-out / skipped comprehensive the rows survived —
|
||||
// refill-only (`force: false`) lets a card already generated today
|
||||
// short-circuit to its cache, which is what keeps the second locale
|
||||
// family cheap.
|
||||
const evicted = result.comprehensive === "generated";
|
||||
result.assessmentsWarmed = await warmPerStatusCaches(
|
||||
userId,
|
||||
locales,
|
||||
|
||||
@@ -28,6 +28,12 @@ const insightsComprehensiveResponse = z
|
||||
.array(z.record(z.string(), z.unknown()))
|
||||
.optional(),
|
||||
metricSource: z.record(z.string(), z.unknown()).optional(),
|
||||
revalidating: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsComprehensiveResponse",
|
||||
|
||||
Reference in new issue
Block a user