diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 067bbe972..a588d12f3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/bugreport/status/route.ts b/src/app/api/bugreport/status/route.ts index cb6944c04..c54ce11b0 100644 --- a/src/app/api/bugreport/status/route.ts +++ b/src/app/api/bugreport/status/route.ts @@ -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 diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 024383604..18daa616f 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -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 + ).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 + ).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); + }); +}); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 40ce62fd1..acd4d896d 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -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 >>, @@ -72,7 +82,7 @@ export const GET = apiHandler(async () => { annotate, ); - return apiSuccess(body); + return apiSuccess({ ...body, revalidating: outcome === "stale" }); }); type AuthedUser = AuthContext["user"]; diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index cb2273623..1bb7c077d 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -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); }); }); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 24d737739..3f0c016af 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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) => { diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 3786b742e..83619abd0 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -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 diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index 55d976bc1..9cba46767 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index 60a2a85f4..443d09682 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -105,8 +105,10 @@ async function postBulkDelete(request: NextRequest): Promise { 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 diff --git a/src/app/api/measurements/restore/__tests__/route.test.ts b/src/app/api/measurements/restore/__tests__/route.test.ts index e1e6747f7..dbbc12592 100644 --- a/src/app/api/measurements/restore/__tests__/route.test.ts +++ b/src/app/api/measurements/restore/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/measurements/restore/route.ts b/src/app/api/measurements/restore/route.ts index 178543fdb..1e3fa8d01 100644 --- a/src/app/api/measurements/restore/route.ts +++ b/src/app/api/measurements/restore/route.ts @@ -106,8 +106,9 @@ async function postRestore(request: NextRequest): Promise { 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 diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 5ef8aedc5..e4ae2fcc5 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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 diff --git a/src/app/api/monitoring/umami-script/__tests__/route.test.ts b/src/app/api/monitoring/umami-script/__tests__/route.test.ts new file mode 100644 index 000000000..641758698 --- /dev/null +++ b/src/app/api/monitoring/umami-script/__tests__/route.test.ts @@ -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: 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; + +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 + // `..` 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(); + }); +}); diff --git a/src/app/api/monitoring/umami-script/route.ts b/src/app/api/monitoring/umami-script/route.ts index 951d61ac2..4482c9c71 100644 --- a/src/app/api/monitoring/umami-script/route.ts +++ b/src/app/api/monitoring/umami-script/route.ts @@ -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: { diff --git a/src/components/insights/__tests__/use-insights-advisor-timeout.test.ts b/src/components/insights/__tests__/use-insights-advisor-timeout.test.ts index 02d5aa889..7828eabad 100644 --- a/src/components/insights/__tests__/use-insights-advisor-timeout.test.ts +++ b/src/components/insights/__tests__/use-insights-advisor-timeout.test.ts @@ -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); + }); +}); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 747a52e78..fb37956ae 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -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("/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 diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index d4054fdfd..fbc95a6cb 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -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({ diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts index e5b406017..f0cb8941e 100644 --- a/src/lib/cache/__tests__/invalidate.test.ts +++ b/src/lib/cache/__tests__/invalidate.test.ts @@ -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", () => { diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index 6a14bc8ec..e315a8dc6 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -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 diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 557d281c5..9ecfa729c 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -522,6 +522,29 @@ export async function cachedSwr( annotateFn?: (fields: { meta: Record }) => void, ttlMsOverride?: number, ): Promise { + 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( + cache: ServerCache, + key: string, + builder: () => Promise, + annotateFn?: (fields: { meta: Record }) => 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( }, }); } - return value; + return { value, outcome }; } /** Test helper — reset every cache in the registry. */ diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index 2d4e5d1a8..3f5ef20e5 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -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, diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index d6fd1d696..379cead21 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -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, diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts index aeec01ce5..85925c0af 100644 --- a/src/lib/openapi/routes/insights.ts +++ b/src/lib/openapi/routes/insights.ts @@ -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",