From 53e87ba3455f725a35daef60bea60a2831961dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 03:24:24 +0200 Subject: [PATCH] perf: consolidate integration status query, add mood-insights SWR, defer below-fold charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Integrations fired five status round-trips on every mount — the consolidated /api/integrations/status alongside the four per-provider /api//status queries. Extend the consolidated endpoint to carry every field the four cards render (Withings activity scope, WHOOP/Fitbit backfill state, moodLog webhook secret + entry count) and drive all four cards off the single envelope. The legacy per-provider routes stay for the iOS/test callers; the web cards no longer hit them. Mood-insights cache was a plain 60 s TTL that was hard-evicted on every mood write, so an active logger re-paid the multi-second cold compute on every entry. Add stale-while-revalidate to ServerCache (wrapSwr / cachedSwr + a markStaleByPrefix that collapses the fresh TTL without dropping the value), give the moodInsights bucket a 10-minute stale window, route the read through cachedSwr, and switch the mood-write invalidation from a hard evict to a mark-stale so the prior aggregate serves immediately while a single background recompute warms a fresh one. Defer the eager Recharts on the mood-insights surface. The trajectory forecast card pulled Recharts into the initial chunk of every trajectory-eligible metric page though the main chart is already deferred; split its fan band into trajectory-fan.tsx behind next/dynamic. The three below-fold mood mini-charts (distribution / weekday / time-of-day) were static imports under an already-dynamic hero line chart; defer them with next/dynamic. Each loader paints a skeleton sized to the chart's own band so the chunk arrives without a layout shift; the charts stay Recharts and visually identical. (cherry picked from commit 2b627893ab6b4d7bc457fc18e6e5585b048190e1) --- src/app/api/integrations/status/route.ts | 44 ++++- src/app/api/mood/insights/route.ts | 9 +- .../insights/derived/trajectory-fan.tsx | 79 ++++++++ .../derived/trajectory-forecast-card.tsx | 65 ++----- .../insights/mood/mood-insights-sections.tsx | 57 ++++-- .../__tests__/integrations-section.test.tsx | 106 ++++------- .../settings/integrations-section.tsx | 159 +++++----------- src/lib/cache/__tests__/invalidate.test.ts | 18 ++ src/lib/cache/__tests__/server-cache.test.ts | 178 ++++++++++++++++++ src/lib/cache/invalidate.ts | 7 +- src/lib/cache/server-cache.ts | 164 +++++++++++++++- 11 files changed, 632 insertions(+), 254 deletions(-) create mode 100644 src/components/insights/derived/trajectory-fan.tsx diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 128ae54d4..9b3d768f8 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -8,10 +8,12 @@ * credentials configured, token expiry, OAuth-connected; moodLog: * credentials configured, enabled flag) * - * Combining both into one fetch removes a third round-trip from the - * Settings page. The legacy /api/withings/status and - * /api/integrations/moodlog/status remain for the existing UI/tests - * — we add this in addition rather than rewriting them. + * This is the single fetch the Settings → Integrations cards read off + * — it carries every field the four cards render (Withings activity + * scope, WHOOP/Fitbit backfill state, moodLog webhook secret + entry + * count) so the per-card /api//status round-trips are gone + * from the web. The legacy per-provider routes stay for the iOS/test + * callers; the web cards no longer hit them. */ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; @@ -22,6 +24,8 @@ import { getPersistentFailureThreshold, type IntegrationKey, } from "@/lib/integrations/status"; +import { hasActivityScope } from "@/lib/withings/client"; +import { readMoodLogSecret } from "@/lib/moodlog-secret"; export const dynamic = "force-dynamic"; @@ -38,6 +42,7 @@ export const GET = apiHandler(async () => { withingsConn, whoopConn, fitbitConn, + moodLogEntryCount, ] = await Promise.all([ getIntegrationStatus(user.id, "withings"), getIntegrationStatus(user.id, "moodlog"), @@ -56,6 +61,7 @@ export const GET = apiHandler(async () => { moodLogApiKeyEncrypted: true, moodLogEnabled: true, moodLogLastSyncedAt: true, + moodLogWebhookSecret: true, }, }), prisma.withingsConnection.findUnique({ @@ -64,6 +70,7 @@ export const GET = apiHandler(async () => { tokenExpiresAt: true, lastSyncedAt: true, createdAt: true, + scope: true, }, }), prisma.whoopConnection.findUnique({ @@ -72,6 +79,7 @@ export const GET = apiHandler(async () => { tokenExpiresAt: true, lastSyncedAt: true, createdAt: true, + backfillCompletedAt: true, }, }), prisma.fitbitConnection.findUnique({ @@ -80,8 +88,13 @@ export const GET = apiHandler(async () => { tokenExpiresAt: true, lastSyncedAt: true, createdAt: true, + backfillCompletedAt: true, }, }), + // v1.7.0 sync — exclude tombstoned rows from the entry count. + prisma.moodEntry.count({ + where: { userId: user.id, deletedAt: null }, + }), ]); const now = Date.now(); @@ -104,13 +117,24 @@ export const GET = apiHandler(async () => { tokenExpired: withingsConn ? withingsConn.tokenExpiresAt.getTime() <= now : null, + // v1.4.25 W5d — `scope` is the comma-separated OAuth scope string; + // `hasActivityScope` is the derived flag the reconnect banner reads. + // Null `scope` = legacy connection that predates activity-scope reads. + scope: withingsConn?.scope ?? null, + hasActivityScope: hasActivityScope(withingsConn?.scope ?? null), } satisfies IntegrationViewModel & WithingsExtras, { ...moodLogStatus, - configured: - !!dbUser?.moodLogUrlEncrypted && !!dbUser?.moodLogApiKeyEncrypted, + // moodLog "configured" tracks the URL alone (the API key is + // optional for the webhook-only path), matching the legacy + // /api/integrations/moodlog/status contract the card relied on. + configured: !!dbUser?.moodLogUrlEncrypted, enabled: dbUser?.moodLogEnabled ?? false, legacyLastSyncedAt: dbUser?.moodLogLastSyncedAt?.toISOString() ?? null, + // V3 audit STILL-V2-C-2: stored secret is AES-GCM encrypted at rest; + // decrypt for the settings page (legacy plaintext is also handled). + webhookSecret: readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null), + entryCount: moodLogEntryCount, } satisfies IntegrationViewModel & MoodLogExtras, { ...whoopStatus, @@ -124,6 +148,7 @@ export const GET = apiHandler(async () => { tokenExpired: whoopConn ? whoopConn.tokenExpiresAt.getTime() <= now : null, + backfillCompleted: whoopConn ? !!whoopConn.backfillCompletedAt : null, } satisfies IntegrationViewModel & WhoopExtras, { ...fitbitStatus, @@ -137,6 +162,7 @@ export const GET = apiHandler(async () => { tokenExpired: fitbitConn ? fitbitConn.tokenExpiresAt.getTime() <= now : null, + backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, } satisfies IntegrationViewModel & FitbitExtras, ], }); @@ -157,12 +183,16 @@ interface WithingsExtras { legacyLastSyncedAt: string | null; tokenExpiresAt: string | null; tokenExpired: boolean | null; + scope: string | null; + hasActivityScope: boolean; } interface MoodLogExtras { configured: boolean; enabled: boolean; legacyLastSyncedAt: string | null; + webhookSecret: string | null; + entryCount: number; } interface WhoopExtras { @@ -172,6 +202,7 @@ interface WhoopExtras { legacyLastSyncedAt: string | null; tokenExpiresAt: string | null; tokenExpired: boolean | null; + backfillCompleted: boolean | null; } interface FitbitExtras { @@ -181,4 +212,5 @@ interface FitbitExtras { legacyLastSyncedAt: string | null; tokenExpiresAt: string | null; tokenExpired: boolean | null; + backfillCompleted: boolean | null; } diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts index a21037371..c6a1b2770 100644 --- a/src/app/api/mood/insights/route.ts +++ b/src/app/api/mood/insights/route.ts @@ -1,7 +1,7 @@ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { fetchMoodAggregates, type MoodAggregates, @@ -19,11 +19,16 @@ export const dynamic = "force-dynamic"; * once per 60-s cache window, the browser only ever receives pre-shaped * data (no raw mood rows). This follows the v1.8.3 anti-freeze posture * — a cheap cached read, never a synchronous LLM call. + * + * v1.12.1 — stale-while-revalidate. On an expired or freshly-marked- + * stale bucket (a mood write marks rather than evicts) the prior + * aggregate is served immediately and a single background recompute + * warms a fresh one, so an active logger never re-pays the cold compute. */ export const GET = apiHandler(async () => { const { user } = await requireAuth(); - const result = await cached( + const result = await cachedSwr( caches.moodInsights as ServerCache, user.id, () => fetchMoodAggregates(user.id), diff --git a/src/components/insights/derived/trajectory-fan.tsx b/src/components/insights/derived/trajectory-fan.tsx new file mode 100644 index 000000000..d3f8b11ad --- /dev/null +++ b/src/components/insights/derived/trajectory-fan.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { + ResponsiveContainer, + ComposedChart, + Area, + Line, + XAxis, + YAxis, + ReferenceLine, +} from "recharts"; + +/** + * v1.12.1 — the Recharts fan band of the trajectory forecast card, + * split into its own module so it can be `next/dynamic`-deferred. + * + * `TrajectoryForecastCard` is mounted by the shared `HealthKitMetricPage` + * on every trajectory-eligible metric page; before this split it pulled + * Recharts into the page's initial chunk eagerly (the main metric chart + * is already deferred via `HealthChartDynamic`). Isolating the only + * Recharts-touching subtree here lets the card chrome render without the + * chart library and lazy-load this chunk only when there's a projection + * to draw — keeping the chart visually identical (same ComposedChart / + * Area / Line / ReferenceLine geometry, same `isAnimationActive={false}`). + */ + +export interface TrajectoryFanPoint { + date: string; + projected: number; + /** Stacked transparent base (= bandLow). */ + base: number; + /** Shaded band height (= bandHigh − bandLow). */ + range: number; +} + +export function TrajectoryFan({ + points, + color, +}: { + points: TrajectoryFanPoint[]; + color: string; +}) { + return ( + + + + + + + + + + + ); +} diff --git a/src/components/insights/derived/trajectory-forecast-card.tsx b/src/components/insights/derived/trajectory-forecast-card.tsx index af1e6cd6c..99208be81 100644 --- a/src/components/insights/derived/trajectory-forecast-card.tsx +++ b/src/components/insights/derived/trajectory-forecast-card.tsx @@ -1,15 +1,7 @@ "use client"; import { useMemo } from "react"; -import { - ResponsiveContainer, - ComposedChart, - Area, - Line, - XAxis, - YAxis, - ReferenceLine, -} from "recharts"; +import dynamic from "next/dynamic"; import { TrendingUp, TrendingDown, Minus } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; @@ -18,6 +10,22 @@ import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; import { useDerivedMetric } from "@/components/insights/derived/use-derived-metric"; + +// v1.12.1 — defer the Recharts fan band so the card chrome (and the whole +// HealthKit metric page that always mounts this card on a trajectory- +// eligible type) no longer pulls Recharts into the initial chunk. The +// loading shell is a plain box that fills the same fixed-height band the +// chart paints, so there is no layout shift when the chunk arrives. +const TrajectoryFanDynamic = dynamic( + () => + import("@/components/insights/derived/trajectory-fan").then((mod) => ({ + default: mod.TrajectoryFan, + })), + { + ssr: false, + loading: () =>
, + }, +); // Type-only — the compute payload never drags the server graph into the // client bundle (the v1.9.0 lesson, mirrored across the derived surfaces). import type { TrajectoryValue } from "@/lib/insights/derived/trajectory"; @@ -303,44 +311,7 @@ export function TrajectoryForecastCard({ className={cn("w-full", compact ? "h-20" : "h-28")} data-slot="trajectory-chart" > - - - - - - - - - - +
{/* The mandatory conditional caveat — never a certainty, never a diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 5f34a0f52..7271c786e 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -1,5 +1,6 @@ "use client"; +import dynamic from "next/dynamic"; import { useQuery } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -9,14 +10,50 @@ import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { MoodHeatmap } from "@/components/charts/mood-heatmap"; -import { - MoodDistributionChart, - type MoodDistributionRow, -} from "./mood-distribution-chart"; -import { - MoodWeekdayChart, - type MoodWeekdayRow, -} from "./mood-weekday-chart"; +// v1.12.1 — the three Recharts mini-charts on this below-fold cluster are +// deferred via `next/dynamic`. The mood hero line chart above them is +// already dynamic on the page, so static-importing these pulled Recharts +// into the initial chunk for no first-paint benefit. Each loader paints a +// skeleton sized to the chart's own band so the deferred chunk arrives +// without a layout shift (charts stay Recharts, visually identical). Types +// stay value-free imports so they don't drag the chunk back in. +import type { MoodDistributionRow } from "./mood-distribution-chart"; +import type { MoodWeekdayRow } from "./mood-weekday-chart"; +import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart"; +const MoodDistributionChart = dynamic( + () => + import("./mood-distribution-chart").then((mod) => ({ + default: mod.MoodDistributionChart, + })), + { + ssr: false, + loading: () => ( + + ), + }, +); +const MoodWeekdayChart = dynamic( + () => + import("./mood-weekday-chart").then((mod) => ({ + default: mod.MoodWeekdayChart, + })), + { + ssr: false, + loading: () => ( + + ), + }, +); +const MoodTimeOfDayChart = dynamic( + () => + import("./mood-time-of-day-chart").then((mod) => ({ + default: mod.MoodTimeOfDayChart, + })), + { + ssr: false, + loading: () => , + }, +); import { MoodTagBreakdown, type MoodTagRow, @@ -34,10 +71,6 @@ import { type MoodNarrativeItem, } from "./mood-narrative-feed"; import { MoodInTargetTile } from "./mood-in-target-tile"; -import { - MoodTimeOfDayChart, - type MoodTimeOfDayPattern, -} from "./mood-time-of-day-chart"; import { MoodStabilityTile, type MoodStabilityData, diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index 0b82f5539..b1f7511e9 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -41,24 +41,13 @@ vi.mock("@/hooks/use-auth", () => ({ }), })); +// v1.12.1 — the four cards now read every field off the single +// /api/integrations/status envelope; the per-card /api//status +// queries are gone. The test mock only needs the consolidated payload. let integrationStatusPayload: unknown = null; -let withingsStatusPayload: unknown = { connected: false, configured: false }; -let moodLogStatusPayload: unknown = { - configured: false, - enabled: false, - lastSyncedAt: null, - entryCount: 0, - webhookSecret: null, -}; function setIntegrationStatus(payload: unknown) { integrationStatusPayload = payload; } -function setWithingsStatus(payload: unknown) { - withingsStatusPayload = payload; -} -function setMoodLogStatus(payload: unknown) { - moodLogStatusPayload = payload; -} vi.mock("@tanstack/react-query", () => ({ useQuery: ({ queryKey }: { queryKey: unknown[] }) => { @@ -78,16 +67,6 @@ vi.mock("@tanstack/react-query", () => ({ isLoading: false, }; } - if (key === "withings/status") { - return { data: withingsStatusPayload, isLoading: false }; - } - if (key === "moodlog-status") { - return { - data: moodLogStatusPayload, - isLoading: false, - refetch: vi.fn(), - }; - } return { data: null, isLoading: false, refetch: vi.fn() }; }, useMutation: () => ({ mutate: vi.fn(), isPending: false }), @@ -119,14 +98,6 @@ function count(html: string, needle: string): number { describe("IntegrationsSection — single-status-display contract (A5)", () => { beforeEach(() => { integrationStatusPayload = null; - withingsStatusPayload = { connected: false, configured: false }; - moodLogStatusPayload = { - configured: false, - enabled: false, - lastSyncedAt: null, - entryCount: 0, - webhookSecret: null, - }; }); it("renders exactly ONE status pill per card when both integrations are healthy", () => { @@ -139,6 +110,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-09T18:00:00.000Z", lastAttemptAt: "2026-05-09T18:00:00.000Z", lastError: null, + connected: true, + configured: true, + legacyLastSyncedAt: "2026-05-09T18:00:00.000Z", + hasActivityScope: true, }, { integration: "moodlog", @@ -146,21 +121,14 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-09T17:00:00.000Z", lastAttemptAt: "2026-05-09T17:00:00.000Z", lastError: null, + configured: true, + enabled: true, + legacyLastSyncedAt: "2026-05-09T17:00:00.000Z", + entryCount: 42, + webhookSecret: "secret123", }, ], }); - setWithingsStatus({ - connected: true, - configured: true, - lastSyncedAt: "2026-05-09T18:00:00.000Z", - }); - setMoodLogStatus({ - configured: true, - enabled: true, - lastSyncedAt: "2026-05-09T17:00:00.000Z", - entryCount: 42, - webhookSecret: "secret123", - }); const html = render(); // Exactly one pill per card → 4 pills total (Withings, moodLog, WHOOP, @@ -186,6 +154,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-09T08:00:00.000Z", lastAttemptAt: "2026-05-09T18:00:00.000Z", lastError: "Withings refresh error: 503 - upstream", + connected: true, + configured: true, + legacyLastSyncedAt: "2026-05-09T08:00:00.000Z", + hasActivityScope: true, }, { integration: "moodlog", @@ -196,11 +168,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { }, ], }); - setWithingsStatus({ - connected: true, - configured: true, - lastSyncedAt: "2026-05-09T08:00:00.000Z", - }); const html = render(); // Pill carries the "Error — reconnect" copy via the pill's @@ -226,6 +193,11 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-08T12:00:00.000Z", lastAttemptAt: "2026-05-09T18:00:00.000Z", lastError: "Withings refresh error: 100 - invalid_grant", + connected: true, + configured: true, + legacyLastSyncedAt: "2026-05-08T12:00:00.000Z", + tokenExpired: true, + hasActivityScope: true, }, { integration: "moodlog", @@ -236,12 +208,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { }, ], }); - setWithingsStatus({ - connected: true, - configured: true, - lastSyncedAt: "2026-05-08T12:00:00.000Z", - tokenExpired: true, - }); const html = render(); expect(html).toContain('data-state="error"'); @@ -293,6 +259,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-08T12:00:00.000Z", lastAttemptAt: "2026-05-09T18:00:00.000Z", lastError: "Withings activity error: 293", + connected: true, + configured: true, + legacyLastSyncedAt: "2026-05-08T12:00:00.000Z", + hasActivityScope: true, }, { integration: "moodlog", @@ -303,11 +273,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { }, ], }); - setWithingsStatus({ - connected: true, - configured: true, - lastSyncedAt: "2026-05-08T12:00:00.000Z", - }); const html = render(); expect(html).toContain('data-state="parked"'); @@ -330,6 +295,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-09T18:00:00.000Z", lastAttemptAt: "2026-05-09T18:00:00.000Z", lastError: null, + connected: true, + configured: true, + legacyLastSyncedAt: "2026-05-09T18:00:00.000Z", + hasActivityScope: true, }, { integration: "moodlog", @@ -337,21 +306,14 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { lastSuccessAt: "2026-05-09T17:00:00.000Z", lastAttemptAt: "2026-05-09T17:00:00.000Z", lastError: null, + configured: true, + enabled: true, + legacyLastSyncedAt: "2026-05-09T17:00:00.000Z", + entryCount: 42, + webhookSecret: "secret123", }, ], }); - setWithingsStatus({ - connected: true, - configured: true, - lastSyncedAt: "2026-05-09T18:00:00.000Z", - }); - setMoodLogStatus({ - configured: true, - enabled: true, - lastSyncedAt: "2026-05-09T17:00:00.000Z", - entryCount: 42, - webhookSecret: "secret123", - }); const html = render(); // Every integration card includes the section divider data-testid so the diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index f2c127af9..2fbfb4616 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -42,14 +42,6 @@ import { queryKeys, } from "@/lib/query-keys"; -interface MoodLogStatus { - configured: boolean; - enabled: boolean; - lastSyncedAt: string | null; - entryCount: number; - webhookSecret: string | null; -} - interface GlobalServiceAvailability { telegramGlobal: boolean; ntfyGlobal: boolean; @@ -88,6 +80,14 @@ interface IntegrationStatusViewModel { tokenExpiresAt?: string | null; tokenExpired?: boolean | null; enabled?: boolean; + // Withings activity-scope reconnect banner. + scope?: string | null; + hasActivityScope?: boolean; + // WHOOP / Fitbit backfill-in-progress note. + backfillCompleted?: boolean | null; + // moodLog webhook secret + entry count. + webhookSecret?: string | null; + entryCount?: number; } interface IntegrationStatusEnvelope { @@ -218,25 +218,17 @@ export function IntegrationsSection() {

- - - + + + {moodLogEnabled && } ); } function WithingsCard({ - isAuthenticated, viewModel, }: { - isAuthenticated: boolean; viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); @@ -254,28 +246,12 @@ function WithingsCard({ ); const queryClient = useQueryClient(); - const { data: status } = useQuery({ - queryKey: queryKeys.withingsStatus(), - queryFn: async () => { - const res = await fetch("/api/withings/status"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as { - connected: boolean; - configured: boolean; - lastSyncedAt?: string | null; - connectedAt?: string; - tokenExpired?: boolean; - // v1.4.25 W5d — reconnect banner conditional. `null` = - // legacy v1.4.24 connection without `user.activity`; the - // user needs to re-auth before steps / active energy / - // distance / floors ingest unlocks. - scope?: string | null; - hasActivityScope?: boolean; - }; - }, - enabled: isAuthenticated, - }); + // v1.12.1 — the Withings status is read off the consolidated + // /api/integrations/status envelope (the `viewModel` prop). The + // per-card /api/withings/status round-trip is gone; the envelope + // carries every field this card renders (connected / configured / + // last-sync / activity scope). + const status = viewModel; const disconnect = useMutation({ mutationFn: async () => { @@ -383,16 +359,16 @@ function WithingsCard({ } // The pill state derives from the cross-integration status - // envelope, but the per-card `lastSyncedAt` is sourced from the - // Withings-specific endpoint so it stays accurate immediately - // after a manual "Sync now" (which only invalidates the per-card - // query). When neither endpoint has answered yet we fall back to - // "disconnected" so the card never renders a status-less header. + // envelope. `legacyLastSyncedAt` is the connection row's own + // last-sync timestamp (carried in the same envelope) so the pill + // still paints a fresh "X min ago" right after a manual sync once + // the envelope refetches. When the envelope hasn't answered yet we + // fall back to "disconnected" so the card never renders status-less. const pillState: IntegrationPillState = status?.connected ? pillStateFor(viewModel) : "disconnected"; const pillLastSyncAt = - status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; + status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null; // v1.4.43 W14 — `parked` and `error` both want the underlying error // message surfaced under the pill (the pill says "what" — the // message says "why"). Other states leave the inline line off. @@ -703,10 +679,8 @@ function WithingsCard({ } function WhoopCard({ - isAuthenticated, viewModel, }: { - isAuthenticated: boolean; viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); @@ -724,24 +698,9 @@ function WhoopCard({ ); const queryClient = useQueryClient(); - const { data: status } = useQuery({ - queryKey: queryKeys.whoopStatus(), - queryFn: async () => { - const res = await fetch("/api/whoop/status"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as { - connected: boolean; - configured: boolean; - lastSyncedAt?: string | null; - connectedAt?: string; - tokenExpired?: boolean; - backfillCompleted?: boolean; - scope?: string | null; - }; - }, - enabled: isAuthenticated, - }); + // v1.12.1 — read off the consolidated /api/integrations/status + // envelope; the per-card /api/whoop/status round-trip is gone. + const status = viewModel; const disconnect = useMutation({ mutationFn: async () => { @@ -853,7 +812,7 @@ function WhoopCard({ ? pillStateFor(viewModel) : "disconnected"; const pillLastSyncAt = - status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; + status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null; const errorMessage = (pillState === "error" || pillState === "parked") && viewModel?.lastError ? viewModel.lastError @@ -1131,14 +1090,13 @@ function WhoopCard({ // v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a // BYO-key credentials form first, then an OAuth connect, then the -// sync/test/disconnect action row + parked-resume banner. Status reads from the -// dedicated /api/fitbit/status (queryKeys.fitbitStatus); the pill/error/parked -// state comes off the cross-integration envelope view-model like WHOOP. +// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status +// reads off the consolidated /api/integrations/status envelope (the per-card +// /api/fitbit/status round-trip is gone); the pill/error/parked state comes off +// the same view-model like WHOOP. function FitbitCard({ - isAuthenticated, viewModel, }: { - isAuthenticated: boolean; viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); @@ -1156,24 +1114,7 @@ function FitbitCard({ ); const queryClient = useQueryClient(); - const { data: status } = useQuery({ - queryKey: queryKeys.fitbitStatus(), - queryFn: async () => { - const res = await fetch("/api/fitbit/status"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as { - connected: boolean; - configured: boolean; - lastSyncedAt?: string | null; - connectedAt?: string; - tokenExpired?: boolean; - backfillCompleted?: boolean; - scope?: string | null; - }; - }, - enabled: isAuthenticated, - }); + const status = viewModel; const disconnect = useMutation({ mutationFn: async () => { @@ -1287,7 +1228,7 @@ function FitbitCard({ ? pillStateFor(viewModel) : "disconnected"; const pillLastSyncAt = - status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; + status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null; const errorMessage = (pillState === "error" || pillState === "parked") && viewModel?.lastError ? viewModel.lastError @@ -1595,14 +1536,15 @@ function MoodLogCard({ const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - const { data: status, refetch: refetchStatus } = useQuery({ - queryKey: queryKeys.moodlogStatus(), - queryFn: async () => { - const res = await fetch("/api/integrations/moodlog/status"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as MoodLogStatus; - }, - }); + // v1.12.1 — read off the consolidated /api/integrations/status + // envelope; the per-card /api/integrations/moodlog/status round-trip + // is gone. Mutations invalidate the envelope so the card's webhook + // secret / entry-count / last-sync repaint on the next refetch. + const status = viewModel; + const refreshStatus = () => + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); async function handleSave(e: React.FormEvent) { e.preventDefault(); @@ -1618,8 +1560,7 @@ function MoodLogCard({ setMsgType("success"); setUrl(""); setApiKey(""); - await refetchStatus(); - queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); + void refreshStatus(); } else { const json = await res.json(); setMsg(json.error || t("settings.savingError")); @@ -1646,8 +1587,7 @@ function MoodLogCard({ ), ); setMsgType("success"); - await refetchStatus(); - queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); + void refreshStatus(); } else { setMsg(t("settings.moodLogSyncFailed")); setMsgType("error"); @@ -1664,20 +1604,19 @@ function MoodLogCard({ if (res.ok) { setMsg(t("settings.moodLogDisconnected")); setMsgType("success"); - await refetchStatus(); - queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); + void refreshStatus(); } } // Mirror Withings logic: the pill state comes from the cross- - // integration envelope; per-card `lastSyncedAt` from the Mood Log - // endpoint so a manual sync paints fresh "X min ago" without - // waiting for the envelope to refetch. + // integration envelope, which also carries `legacyLastSyncedAt` + // (the user's moodLog last-sync timestamp) so a manual sync paints + // a fresh "X min ago" once the envelope refetches. const pillState: IntegrationPillState = status?.configured ? pillStateFor(viewModel) : "disconnected"; const pillLastSyncAt = - status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; + status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null; const errorMessage = pillState === "error" && viewModel?.lastError ? viewModel.lastError : null; diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts index 47cb3c014..d927feac0 100644 --- a/src/lib/cache/__tests__/invalidate.test.ts +++ b/src/lib/cache/__tests__/invalidate.test.ts @@ -59,6 +59,10 @@ async function primeAllCaches(): Promise { await cached(caches.moodAnalytics, USER_A, async () => ({ mood: 1 })); await cached(caches.moodAnalytics, USER_B, async () => ({ mood: 2 })); + // v1.12.1 — mood-insights aggregate cache (SWR bucket). + await cached(caches.moodInsights, USER_A, async () => ({ mi: 1 })); + await cached(caches.moodInsights, USER_B, async () => ({ mi: 2 })); + await cached(caches.dashboardWidgets, USER_A, async () => ({ d: 1 })); await cached(caches.dashboardWidgets, USER_B, async () => ({ d: 2 })); @@ -114,6 +118,20 @@ describe("invalidateUserMood", () => { expect(caches.analytics.get(dashboardSnapshotCacheKey(USER_A))).toBeNull(); expect(caches.moodAnalytics.get(USER_B)).not.toBeNull(); expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull(); + + // v1.12.1 — mood-insights is marked stale, not hard-evicted: the + // prior value is still serveable within the SWR window so the next + // reader pays no cold compute. (A plain `get()` would treat the + // collapsed TTL as expired and drop it, so assert via getAllowStale + // first — it must not consume the entry.) User B stays fresh. + expect(caches.moodInsights.getAllowStale(USER_A)).toEqual({ + value: { mi: 1 }, + stale: true, + }); + expect(caches.moodInsights.getAllowStale(USER_B)).toEqual({ + value: { mi: 2 }, + stale: false, + }); }); }); diff --git a/src/lib/cache/__tests__/server-cache.test.ts b/src/lib/cache/__tests__/server-cache.test.ts index e2d8bc52e..6088a2e25 100644 --- a/src/lib/cache/__tests__/server-cache.test.ts +++ b/src/lib/cache/__tests__/server-cache.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { __resetAllCachesForTests, cached, + cachedSwr, caches, hashCacheKey, ServerCache, @@ -222,6 +223,183 @@ describe("cached() observability wrapper", () => { }); }); +describe("ServerCache stale-while-revalidate (wrapSwr / cachedSwr)", () => { + it("serves a fresh hit without rebuilding", async () => { + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 60_000, + staleTtlMs: 600_000, + }); + const builder = vi.fn(async () => "v"); + const first = await cache.wrapSwr("k", builder); + expect(first.outcome).toBe("miss"); + const second = await cache.wrapSwr("k", builder); + expect(second.outcome).toBe("hit"); + expect(builder).toHaveBeenCalledTimes(1); + }); + + it("serves the stale value immediately and revalidates in the background", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + let calls = 0; + const builder = vi.fn(async () => `v${++calls}`); + + // Cold compute → v1. + const cold = await cache.wrapSwr("k", builder); + expect(cold.value).toBe("v1"); + expect(cold.outcome).toBe("miss"); + + // Past the fresh TTL but inside the stale window. + vi.advanceTimersByTime(1500); + const stale = await cache.wrapSwr("k", builder); + // The active caller gets the prior value immediately — it did NOT + // pay the cold compute. + expect(stale.value).toBe("v1"); + expect(stale.outcome).toBe("stale"); + + // The background recompute settles to a fresh value. + await vi.runAllTimersAsync(); + expect(builder).toHaveBeenCalledTimes(2); + const warm = await cache.wrapSwr("k", builder); + expect(warm.value).toBe("v2"); + expect(warm.outcome).toBe("hit"); + }); + + it("coalesces a burst of stale reads into a single background rebuild", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + // The cold build resolves immediately; the rebuild is gated on a + // manual promise so all three stale reads fire before it settles — + // proving they coalesce onto one in-flight rebuild. + let releaseRebuild: (() => void) | null = null; + let calls = 0; + const builder = vi.fn(async () => { + calls += 1; + if (calls === 1) return "v1"; + await new Promise((r) => { + releaseRebuild = r; + }); + return "v2"; + }); + await cache.wrapSwr("k", builder); + vi.advanceTimersByTime(1500); + + const a = await cache.wrapSwr("k", builder); + const b = await cache.wrapSwr("k", builder); + const c = await cache.wrapSwr("k", builder); + expect([a.outcome, b.outcome, c.outcome]).toEqual([ + "stale", + "stale", + "stale", + ]); + // The single in-flight rebuild is still pending across the burst. + expect(builder).toHaveBeenCalledTimes(2); + releaseRebuild!(); + await vi.runAllTimersAsync(); + // Still exactly one cold build + one background rebuild. + expect(builder).toHaveBeenCalledTimes(2); + }); + + it("treats an entry past the stale window as a hard miss", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 2000, + }); + let calls = 0; + const builder = vi.fn(async () => `v${++calls}`); + await cache.wrapSwr("k", builder); + // Past ttl + staleTtl → the stale window is closed. + vi.advanceTimersByTime(3500); + const miss = await cache.wrapSwr("k", builder); + expect(miss.outcome).toBe("miss"); + expect(miss.value).toBe("v2"); + }); + + it("markStaleByPrefix keeps serving the prior value under SWR (no cold re-pay)", async () => { + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 60_000, + staleTtlMs: 600_000, + }); + let calls = 0; + const builder = vi.fn(async () => `v${++calls}`); + await cache.wrapSwr("u1|k", builder); + + // Simulate a mood write marking the bucket stale rather than evicting. + expect(cache.markStaleByPrefix("u1|")).toBe(1); + + // The next read still serves the prior value immediately (stale), + // not a hard miss — so the active logger doesn't re-pay the compute. + const afterMark = await cache.wrapSwr("u1|k", builder); + expect(afterMark.outcome).toBe("stale"); + expect(afterMark.value).toBe("v1"); + }); + + it("a rejected background rebuild keeps the stale value and retries next read", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + let calls = 0; + const builder = vi.fn(async () => { + calls += 1; + if (calls === 2) throw new Error("transient"); + return `v${calls}`; + }); + await cache.wrapSwr("k", builder); + vi.advanceTimersByTime(1500); + + // Stale read triggers a background rebuild that rejects. + const stale = await cache.wrapSwr("k", builder); + expect(stale.value).toBe("v1"); + await vi.runAllTimersAsync(); + + // The prior value still serves; the next read retries the build. + const retry = await cache.wrapSwr("k", builder); + expect(retry.outcome).toBe("stale"); + expect(retry.value).toBe("v1"); + await vi.runAllTimersAsync(); + const warm = await cache.wrapSwr("k", builder); + expect(warm.value).toBe("v3"); + }); + + it("cachedSwr annotates the outcome (hit / stale / miss)", async () => { + vi.useFakeTimers(); + const cache = new ServerCache({ + name: "moodInsights", + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + const annotate = vi.fn(); + const builder = vi.fn(async () => "v"); + + await cachedSwr(cache, "u1", builder, annotate); + expect(annotate).toHaveBeenLastCalledWith({ + meta: expect.objectContaining({ "cache.moodInsights.outcome": "miss" }), + }); + + vi.advanceTimersByTime(1500); + annotate.mockClear(); + await cachedSwr(cache, "u1", builder, annotate); + expect(annotate).toHaveBeenLastCalledWith({ + meta: expect.objectContaining({ "cache.moodInsights.outcome": "stale" }), + }); + }); +}); + describe("global cache registry", () => { it("exposes the eight blueprint caches as module-scope singletons", () => { expect(caches.analytics).toBeDefined(); diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index f8b57b4bd..c919aa245 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -78,7 +78,12 @@ export function invalidateUserMood(userId: string): void { caches.insightsTargets.deleteByPrefix(userId); // v1.8.5 — mood writes dirty every dimension of the mood-insights // aggregate (heatmap cell, distribution, weekday, tag breakdown). - caches.moodInsights.deleteByPrefix(userId); + // v1.12.1 — mark stale rather than hard-evict: the mood-insights read + // is a multi-second cold compute, so an active logger writing back to + // back used to re-pay it on every entry. Marking stale keeps serving + // the prior aggregate (within the SWR window) while a single + // background recompute warms a fresh one. + caches.moodInsights.markStaleByPrefix(userId); } /** diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 638df3f07..741af07cb 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -41,6 +41,15 @@ export interface ServerCacheOptions { readonly maxEntries: number; /** Time-to-live in milliseconds. Expired entries miss on read. */ readonly ttlMs: number; + /** + * v1.12.1 — stale-while-revalidate window in milliseconds. When set, + * an entry whose `ttlMs` has lapsed but is still inside + * `ttlMs + staleTtlMs` is served immediately (stale) by `wrapSwr()`, + * which kicks off a single background recompute to refresh it. Past + * the stale window the entry is a hard miss like any other. Leave + * unset for the legacy hard-TTL behaviour. + */ + readonly staleTtlMs?: number; /** * Optional name for observability — surfaced in `stats().name` so the * wide-event meta keys can carry the cache identity without the @@ -59,7 +68,10 @@ export interface ServerCacheStats { } export class ServerCache { - private readonly map = new Map(); + private readonly map = new Map< + string, + { expiresAt: number; staleUntil: number; value: T } + >(); private readonly pending = new Map>(); private hits = 0; private misses = 0; @@ -114,12 +126,61 @@ export class ServerCache { this.evictions += 1; } } + const now = Date.now(); + const expiresAt = now + (ttlMsOverride ?? this.opts.ttlMs); this.map.set(key, { - expiresAt: Date.now() + (ttlMsOverride ?? this.opts.ttlMs), + expiresAt, + // The stale window extends past the fresh TTL only when the + // bucket opted into SWR; otherwise `staleUntil === expiresAt` + // so `getAllowStale` behaves exactly like `get`. + staleUntil: expiresAt + (this.opts.staleTtlMs ?? 0), value, }); } + /** + * v1.12.1 — read a key allowing a stale entry inside the SWR window. + * Returns the value plus a `stale` flag (true when the fresh TTL has + * lapsed but the entry is still inside `staleTtlMs`). Past the stale + * window the entry is removed and `null` is returned. LRU-touches a + * live or stale entry so a hot key never falls out under pressure. + */ + getAllowStale(key: string): { value: T; stale: boolean } | null { + const entry = this.map.get(key); + if (!entry) return null; + const now = Date.now(); + if (entry.staleUntil <= now) { + this.map.delete(key); + return null; + } + // LRU touch. + this.map.delete(key); + this.map.set(key, entry); + return { value: entry.value, stale: entry.expiresAt <= now }; + } + + /** + * v1.12.1 — mark every entry under `prefix` stale without dropping it. + * Collapses the fresh TTL to "now" so the next `wrapSwr` read serves + * the prior value immediately (within the stale window) while a single + * coalesced recompute warms a fresh value. This replaces the + * hard-evict on a mood write so an active logger no longer re-pays the + * cold compute on every entry. No-op for buckets without `staleTtlMs` + * (the entry is already at/over its hard TTL after this, so the next + * read is a clean miss — identical to the old evict behaviour). + */ + markStaleByPrefix(prefix: string): number { + let marked = 0; + const now = Date.now(); + for (const [key, entry] of this.map) { + if (key.startsWith(prefix)) { + entry.expiresAt = now; + marked += 1; + } + } + return marked; + } + /** Single-key eviction. Returns true when the key was present. */ delete(key: string): boolean { return this.map.delete(key); @@ -187,6 +248,64 @@ export class ServerCache { return { value, outcome: "miss" }; } + /** + * v1.12.1 — stale-while-revalidate read. On a fresh hit it returns the + * cached value like `wrap`. On a stale hit (entry past its fresh TTL + * but inside the SWR window) it returns the prior value IMMEDIATELY + * and kicks off a single background recompute — the active caller + * never pays the cold compute. On a hard miss / cold key it falls back + * to `wrap`'s single-flight builder and awaits it. + * + * Background revalidation reuses the `pending` single-flight slot so a + * burst of stale reads coalesces into one recompute. A rejected + * background build is swallowed (the stale value already served) and + * clears `pending` so the next read retries. + */ + async wrapSwr( + key: string, + builder: () => Promise, + ttlMsOverride?: number, + ): Promise<{ + value: T; + outcome: "hit" | "stale" | "miss" | "stampede"; + }> { + const cachedEntry = this.getAllowStale(key); + if (cachedEntry && !cachedEntry.stale) { + this.hits += 1; + return { value: cachedEntry.value, outcome: "hit" }; + } + + if (cachedEntry && cachedEntry.stale) { + // Serve stale now; revalidate in the background under the + // single-flight slot so concurrent stale reads share one rebuild. + this.hits += 1; + if (!this.pending.has(key)) { + const refresh = builder() + .then((value) => { + this.set(key, value, ttlMsOverride); + return value; + }) + .catch((err) => { + // The stale value already went out; don't crash the worker. + // Re-throw so the join path of any awaiting caller sees it. + throw err; + }) + .finally(() => { + this.pending.delete(key); + }); + this.pending.set(key, refresh); + // Detach: the foreground caller does not await the rebuild. + // Swallow the rejection on the detached handle so an + // unhandled-rejection warning never fires. + refresh.catch(() => {}); + } + return { value: cachedEntry.value, outcome: "stale" }; + } + + // Hard miss / cold key — fall back to the awaited single-flight path. + return this.wrap(key, builder, ttlMsOverride); + } + /** Snapshot of per-instance counters for the wide-event annotation. */ stats(): ServerCacheStats { return { @@ -264,13 +383,22 @@ export const caches = { * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution, * weekday, tags, cross-metric correlations) for the Mood Insights page. * Read on every `/insights/mood` mount; bounded 365-day live read behind - * the cache. 60 s TTL matches `moodAnalytics`; invalidated alongside it - * on a mood write via `invalidateUserMood`. + * the cache. 60 s fresh TTL matches `moodAnalytics`. + * + * v1.12.1 — stale-while-revalidate. The read route uses `cachedSwr` and + * a mood write marks the bucket stale (not a hard evict) via + * `invalidateUserMood`. An active logger now serves the prior aggregate + * immediately while a single background recompute warms a fresh one, + * instead of re-paying the multi-second cold compute on every entry + * (the v1.8.7 "sync evicting the cache all day" class, on mood). The + * 10-minute stale window bounds how old a served aggregate can be when + * no reader has triggered a revalidation. */ moodInsights: new ServerCache({ name: "moodInsights", maxEntries: 1000, ttlMs: 60_000, + staleTtlMs: 600_000, }), /** * v1.4.36 W1 — `/api/insights/targets` is read on every Insights @@ -342,6 +470,34 @@ export async function cached( return value; } +/** + * v1.12.1 — stale-while-revalidate read-through. Same observability + * contract as `cached` (annotates the wide-event with the outcome + + * key hash) but routes through `wrapSwr`: a stale entry inside the + * bucket's `staleTtlMs` window is served immediately while a single + * background recompute refreshes it, so an active caller never pays + * the cold compute on an expired or freshly-marked-stale bucket. + */ +export async function cachedSwr( + cache: ServerCache, + key: string, + builder: () => Promise, + annotateFn?: (fields: { meta: Record }) => void, + ttlMsOverride?: number, +): Promise { + const { value, outcome } = await cache.wrapSwr(key, builder, ttlMsOverride); + if (annotateFn) { + const name = cache.stats().name; + annotateFn({ + meta: { + [`cache.${name}.outcome`]: outcome, + [`cache.${name}.key_hash`]: hashCacheKey(key), + }, + }); + } + return value; +} + /** Test helper — reset every cache in the registry. */ export function __resetAllCachesForTests(): void { for (const cache of Object.values(caches)) {