From 99d8e4b113bc5ee81754d5808cb474ba65041246 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 19:15:47 +0000 Subject: [PATCH 1/4] fix: auto-refresh stale materialized views when activity list is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the activity list query returns zero results, check if the base fitness.activity table has data for the user. If it does, the materialized views (v_activity, activity_summary) are stale — refresh them and retry the query. This self-heals after crash recovery or failed view refreshes that leave views populated but outdated. Adds logging and Sentry alerts when stale views are detected. https://claude.ai/code/session_01Sye7TLmEeSX26DZYLUVkgL --- .../src/repositories/activity-repository.ts | 20 ++++++ packages/server/src/routers/activity.test.ts | 70 ++++++++++++++++++- packages/server/src/routers/activity.ts | 33 ++++++++- 3 files changed, 119 insertions(+), 4 deletions(-) diff --git a/packages/server/src/repositories/activity-repository.ts b/packages/server/src/repositories/activity-repository.ts index 7583bb600e..0fa892c2e5 100644 --- a/packages/server/src/repositories/activity-repository.ts +++ b/packages/server/src/repositories/activity-repository.ts @@ -335,6 +335,26 @@ export class ActivityRepository extends BaseRepository { return mapHrZones(rows); } + /** Count activities in the base table (not the materialized view) for this user. */ + async baseTableCount(): Promise { + const rows = await this.query( + z.object({ count: z.coerce.number() }), + sql`SELECT count(*)::int AS count FROM fitness.activity WHERE user_id = ${this.userId}`, + ); + return rows[0]?.count ?? 0; + } + + /** Refresh the activity-related materialized views. */ + async refreshActivityViews(): Promise { + for (const view of ["fitness.v_activity", "fitness.activity_summary"]) { + try { + await this.db.execute(sql.raw(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)); + } catch { + await this.db.execute(sql.raw(`REFRESH MATERIALIZED VIEW ${view}`)); + } + } + } + /** Delete an activity by ID. */ async delete(activityId: string): Promise { await this.db.execute(sql` diff --git a/packages/server/src/routers/activity.test.ts b/packages/server/src/routers/activity.test.ts index fcd546143b..029706a057 100644 --- a/packages/server/src/routers/activity.test.ts +++ b/packages/server/src/routers/activity.test.ts @@ -33,6 +33,15 @@ vi.mock("../lib/typed-sql.ts", async (importOriginal) => { }; }); +vi.mock("@sentry/node", () => ({ + captureMessage: vi.fn(), + captureException: vi.fn(), +})); + +vi.mock("../logger.ts", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + vi.mock("./sync.ts", () => ({ ensureProvidersRegistered: vi.fn(async () => {}), })); @@ -138,9 +147,64 @@ describe("activityRouter", () => { }); it("returns empty items and zero totalCount when no activities", async () => { - const caller = makeCaller([]); + const execute = vi.fn().mockResolvedValue([]); + const caller = createCaller({ + db: { execute }, + userId: "user-1", + timezone: "UTC", + }); + const result = await caller.list({ days: 30 }); + expect(result).toEqual({ items: [], totalCount: 0 }); + // Should check base table when view returns empty + expect(execute).toHaveBeenCalledTimes(2); // list query + base table check + }); + + it("refreshes stale views and retries when view is empty but base table has data", async () => { + const activityRow = { + id: "a1", + started_at: "2024-01-01 10:00:00+00", + ended_at: "2024-01-01 11:00:00+00", + activity_type: "cycling", + name: "Morning Ride", + provider_id: "wahoo", + source_providers: ["wahoo"], + avg_hr: 150, + max_hr: 180, + avg_power: 200, + distance_meters: 30000, + total_count: 1, + }; + const execute = vi + .fn() + .mockResolvedValueOnce([]) // 1. list from v_activity: empty + .mockResolvedValueOnce([{ count: 1 }]) // 2. base table count: has data + .mockResolvedValueOnce([]) // 3. REFRESH v_activity + .mockResolvedValueOnce([]) // 4. REFRESH activity_summary + .mockResolvedValueOnce([activityRow]); // 5. retry list + const caller = createCaller({ + db: { execute }, + userId: "user-1", + timezone: "UTC", + }); + const result = await caller.list({ days: 30, limit: 20, offset: 0 }); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ id: "a1" }); + expect(execute).toHaveBeenCalledTimes(5); + }); + + it("returns empty when both view and base table are empty (genuinely no data)", async () => { + const execute = vi + .fn() + .mockResolvedValueOnce([]) // 1. list from v_activity: empty + .mockResolvedValueOnce([{ count: 0 }]); // 2. base table count: no data + const caller = createCaller({ + db: { execute }, + userId: "user-1", + timezone: "UTC", + }); const result = await caller.list({ days: 30 }); expect(result).toEqual({ items: [], totalCount: 0 }); + expect(execute).toHaveBeenCalledTimes(2); // no refresh or retry }); it("uses default limit of 20 and offset of 0", async () => { @@ -151,8 +215,8 @@ describe("activityRouter", () => { timezone: "UTC", }); await caller.list({ days: 30 }); - // Verify the query was called (default params applied) - expect(execute).toHaveBeenCalledTimes(1); + // list query + base table count (stale view check) + expect(execute).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/server/src/routers/activity.ts b/packages/server/src/routers/activity.ts index e89bb266b3..8b5f6ef94c 100644 --- a/packages/server/src/routers/activity.ts +++ b/packages/server/src/routers/activity.ts @@ -1,7 +1,9 @@ +import * as Sentry from "@sentry/node"; import { TRPCError } from "@trpc/server"; import { getProvider } from "dofek/providers/registry"; import { z } from "zod"; import { endDateSchema } from "../lib/date-window.ts"; +import { logger } from "../logger.ts"; import { Activity, type ActivityDetail } from "../models/activity.ts"; import { ActivityRepository, @@ -45,7 +47,36 @@ export const activityRouter = router({ ) .query(async ({ ctx, input }) => { const repo = new ActivityRepository(ctx.db, ctx.userId, ctx.timezone); - return repo.list(input); + const result = await repo.list(input); + + // Self-healing: if the materialized view returns no results but the base + // table has data, the views are stale (e.g. after a crash recovery or + // failed view refresh). Refresh them and retry the query. + if (result.totalCount === 0) { + const baseCount = await repo.baseTableCount(); + if (baseCount > 0) { + logger.warn( + `[activity] Stale views detected for user ${ctx.userId}: ` + + `${baseCount} activities in base table but 0 in materialized view. Refreshing.`, + ); + Sentry.captureMessage("Stale activity materialized views detected", { + level: "warning", + tags: { userId: ctx.userId }, + extra: { baseCount }, + }); + try { + await repo.refreshActivityViews(); + return repo.list(input); + } catch (refreshError) { + logger.error(`[activity] Failed to refresh stale views: ${refreshError}`); + Sentry.captureException(refreshError, { + tags: { userId: ctx.userId, context: "staleViewRefresh" }, + }); + } + } + } + + return result; }), byId: cachedProtectedQuery(CacheTTL.MEDIUM) From bcf1d36638e03d7b0d0384167b8c1de151a56369 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 19:26:06 +0000 Subject: [PATCH 2/4] fix: show error state instead of empty when activity query fails The Dashboard and mobile activities screen silently showed "No recent activities" when the tRPC query errored (e.g., server 500, network failure). Now both platforms show a distinct "Failed to load activities" error message, matching the existing error pattern used elsewhere. https://claude.ai/code/session_01Sye7TLmEeSX26DZYLUVkgL --- packages/mobile/app/activities.tsx | 8 ++++++++ packages/web/src/components/ActivityList.test.tsx | 6 ++++++ packages/web/src/components/ActivityList.tsx | 6 ++++++ packages/web/src/pages/Dashboard.tsx | 1 + 4 files changed, 21 insertions(+) diff --git a/packages/mobile/app/activities.tsx b/packages/mobile/app/activities.tsx index 50e8ffad52..fdd1ced1cb 100644 --- a/packages/mobile/app/activities.tsx +++ b/packages/mobile/app/activities.tsx @@ -84,6 +84,8 @@ export default function ActivitiesScreen() { ListEmptyComponent={ query.isLoading ? ( + ) : query.isError ? ( + Failed to load activities. ) : ( No activities found ) @@ -156,6 +158,12 @@ const styles = StyleSheet.create({ marginTop: 40, fontSize: 14, }, + error: { + color: "#f87171", + textAlign: "center", + marginTop: 40, + fontSize: 14, + }, pagination: { flexDirection: "row", alignItems: "center", diff --git a/packages/web/src/components/ActivityList.test.tsx b/packages/web/src/components/ActivityList.test.tsx index aea2eea577..413974ef0b 100644 --- a/packages/web/src/components/ActivityList.test.tsx +++ b/packages/web/src/components/ActivityList.test.tsx @@ -65,6 +65,12 @@ describe("ActivityList", () => { expect(screen.getByText("No recent activities")).toBeDefined(); }); + it("shows error state when error prop is true", () => { + renderWithUnits(); + expect(screen.getByText("Failed to load activities.")).toBeDefined(); + expect(screen.queryByText("No recent activities")).toBeNull(); + }); + it("renders loading state", () => { renderWithUnits(); // ChartLoadingSkeleton should be visible diff --git a/packages/web/src/components/ActivityList.tsx b/packages/web/src/components/ActivityList.tsx index 2771d0e67e..afba8a332d 100644 --- a/packages/web/src/components/ActivityList.tsx +++ b/packages/web/src/components/ActivityList.tsx @@ -19,6 +19,7 @@ export interface Activity { interface ActivityListProps { activities: Activity[]; loading?: boolean; + error?: boolean; totalCount?: number; page?: number; pageSize?: number; @@ -42,6 +43,7 @@ function formatActivityDuration(startedAt: string, endedAt: string | null): stri export function ActivityList({ activities, loading, + error, totalCount, page, pageSize, @@ -54,6 +56,10 @@ export function ActivityList({ return ; } + if (error) { + return

Failed to load activities.

; + } + if (activities.length === 0) { return
No recent activities
; } diff --git a/packages/web/src/pages/Dashboard.tsx b/packages/web/src/pages/Dashboard.tsx index d6998db29d..c0678e0beb 100644 --- a/packages/web/src/pages/Dashboard.tsx +++ b/packages/web/src/pages/Dashboard.tsx @@ -475,6 +475,7 @@ export function Dashboard() { Date: Wed, 8 Apr 2026 19:54:03 +0000 Subject: [PATCH 3/4] fix: scope stale view check to time window and first page only Address CR feedback: - baseTableCount() now takes (endDate, days) params to apply the same time-window filter as list(), avoiding false-positive refreshes for users with only historical activities - Stale view check only runs on offset === 0 (first page), preventing expensive refreshes on legitimate empty later pages - Error logging now includes stack trace via refreshError.stack instead of toString() which drops actionable context https://claude.ai/code/session_01Sye7TLmEeSX26DZYLUVkgL --- .../src/repositories/activity-repository.ts | 8 +++++--- packages/server/src/routers/activity.test.ts | 13 +++++++++++++ packages/server/src/routers/activity.ts | 18 ++++++++++++------ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/server/src/repositories/activity-repository.ts b/packages/server/src/repositories/activity-repository.ts index 0fa892c2e5..7c63d52bbd 100644 --- a/packages/server/src/repositories/activity-repository.ts +++ b/packages/server/src/repositories/activity-repository.ts @@ -335,11 +335,13 @@ export class ActivityRepository extends BaseRepository { return mapHrZones(rows); } - /** Count activities in the base table (not the materialized view) for this user. */ - async baseTableCount(): Promise { + /** Count activities in the base table (not the materialized view) for this user within a time window. */ + async baseTableCount(endDate: string, days: number): Promise { const rows = await this.query( z.object({ count: z.coerce.number() }), - sql`SELECT count(*)::int AS count FROM fitness.activity WHERE user_id = ${this.userId}`, + sql`SELECT count(*)::int AS count FROM fitness.activity + WHERE user_id = ${this.userId} + AND started_at > ${timestampWindowStart(endDate, days)}`, ); return rows[0]?.count ?? 0; } diff --git a/packages/server/src/routers/activity.test.ts b/packages/server/src/routers/activity.test.ts index 029706a057..925405b40a 100644 --- a/packages/server/src/routers/activity.test.ts +++ b/packages/server/src/routers/activity.test.ts @@ -207,6 +207,19 @@ describe("activityRouter", () => { expect(execute).toHaveBeenCalledTimes(2); // no refresh or retry }); + it("skips stale view check on non-first pages", async () => { + const execute = vi.fn().mockResolvedValue([]); + const caller = createCaller({ + db: { execute }, + userId: "user-1", + timezone: "UTC", + }); + const result = await caller.list({ days: 30, limit: 20, offset: 20 }); + expect(result).toEqual({ items: [], totalCount: 0 }); + // Only the list query — no base table check on offset > 0 + expect(execute).toHaveBeenCalledTimes(1); + }); + it("uses default limit of 20 and offset of 0", async () => { const execute = vi.fn().mockResolvedValue([]); const caller = createCaller({ diff --git a/packages/server/src/routers/activity.ts b/packages/server/src/routers/activity.ts index 8b5f6ef94c..b67bb32930 100644 --- a/packages/server/src/routers/activity.ts +++ b/packages/server/src/routers/activity.ts @@ -49,11 +49,13 @@ export const activityRouter = router({ const repo = new ActivityRepository(ctx.db, ctx.userId, ctx.timezone); const result = await repo.list(input); - // Self-healing: if the materialized view returns no results but the base - // table has data, the views are stale (e.g. after a crash recovery or - // failed view refresh). Refresh them and retry the query. - if (result.totalCount === 0) { - const baseCount = await repo.baseTableCount(); + // Self-healing: if the materialized view returns no results on the first + // page but the base table has data in the same time window, the views are + // stale (e.g. after a crash recovery or failed view refresh). Refresh + // them and retry the query. Only check on the first page to avoid + // expensive refreshes on legitimate empty later pages. + if (input.offset === 0 && result.items.length === 0) { + const baseCount = await repo.baseTableCount(input.endDate, input.days); if (baseCount > 0) { logger.warn( `[activity] Stale views detected for user ${ctx.userId}: ` + @@ -68,7 +70,11 @@ export const activityRouter = router({ await repo.refreshActivityViews(); return repo.list(input); } catch (refreshError) { - logger.error(`[activity] Failed to refresh stale views: ${refreshError}`); + const errorDetail = + refreshError instanceof Error + ? (refreshError.stack ?? refreshError.message) + : String(refreshError); + logger.error(`[activity] Failed to refresh stale views: ${errorDetail}`); Sentry.captureException(refreshError, { tags: { userId: ctx.userId, context: "staleViewRefresh" }, }); From 365f4f9a1be1568a855183e495ac3d39e13b8154 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 19:57:13 +0000 Subject: [PATCH 4/4] feat: add admin.refreshViews endpoint to force-refresh all materialized views Adds an admin-only mutation that refreshes all 5 materialized views (v_activity, v_sleep, v_body_measurement, v_daily_metrics, activity_summary) with CONCURRENTLY fallback. Useful for recovering from stale views after crash recovery or failed sync refreshes. https://claude.ai/code/session_01Sye7TLmEeSX26DZYLUVkgL --- packages/server/src/routers/admin.test.ts | 34 +++++++++++++++++++++++ packages/server/src/routers/admin.ts | 25 +++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/packages/server/src/routers/admin.test.ts b/packages/server/src/routers/admin.test.ts index 22374a71cb..cff0e0cc30 100644 --- a/packages/server/src/routers/admin.test.ts +++ b/packages/server/src/routers/admin.test.ts @@ -12,6 +12,10 @@ vi.mock("../lib/start-worker.ts", () => ({ startWorker: vi.fn(), })); +vi.mock("../logger.ts", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + vi.mock("../trpc.ts", async () => { const { initTRPC } = await import("@trpc/server"); const trpc = initTRPC @@ -436,6 +440,36 @@ describe("adminRouter", () => { }); }); + describe("refreshViews", () => { + it("refreshes all materialized views and returns view names", async () => { + const execute = vi.fn().mockResolvedValue([]); + const caller = makeCaller(execute); + const result = await caller.refreshViews(); + expect(result.refreshed).toEqual([ + "fitness.v_activity", + "fitness.v_sleep", + "fitness.v_body_measurement", + "fitness.v_daily_metrics", + "fitness.activity_summary", + ]); + // 5 views × REFRESH MATERIALIZED VIEW CONCURRENTLY + expect(execute).toHaveBeenCalledTimes(5); + }); + + it("falls back to non-concurrent refresh on error", async () => { + const execute = vi + .fn() + .mockRejectedValueOnce(new Error("has not been populated")) + .mockResolvedValueOnce([]) // fallback non-concurrent + .mockResolvedValue([]); // remaining views + const caller = makeCaller(execute); + const result = await caller.refreshViews(); + expect(result.refreshed).toHaveLength(5); + // 1 failed concurrent + 1 fallback + 4 remaining = 6 + expect(execute).toHaveBeenCalledTimes(6); + }); + }); + describe("trainingExportStatus", () => { it("returns watermark data", async () => { const rows = [ diff --git a/packages/server/src/routers/admin.ts b/packages/server/src/routers/admin.ts index 0937e978d3..e18bfab114 100644 --- a/packages/server/src/routers/admin.ts +++ b/packages/server/src/routers/admin.ts @@ -3,8 +3,17 @@ import { sql } from "drizzle-orm"; import { z } from "zod"; import { startWorker } from "../lib/start-worker.ts"; import { executeWithSchema, timestampStringSchema } from "../lib/typed-sql.ts"; +import { logger } from "../logger.ts"; import { adminProcedure, router } from "../trpc.ts"; +const ALL_MATERIALIZED_VIEWS = [ + "fitness.v_activity", + "fitness.v_sleep", + "fitness.v_body_measurement", + "fitness.v_daily_metrics", + "fitness.activity_summary", +] as const; + const trainingExportQueue = createTrainingExportQueue(); // ── Schemas for admin queries ── @@ -443,6 +452,22 @@ export const adminRouter = router({ return { jobId: String(job.id) }; }), + /** Force-refresh all materialized views (dedup + rollup). */ + refreshViews: adminProcedure.mutation(async ({ ctx }) => { + logger.info("[admin] Refreshing all materialized views"); + const refreshed: string[] = []; + for (const view of ALL_MATERIALIZED_VIEWS) { + try { + await ctx.db.execute(sql.raw(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)); + } catch { + await ctx.db.execute(sql.raw(`REFRESH MATERIALIZED VIEW ${view}`)); + } + refreshed.push(view); + } + logger.info(`[admin] Refreshed ${refreshed.length} materialized views`); + return { refreshed }; + }), + /** Get training export watermark status */ trainingExportStatus: adminProcedure.query(async ({ ctx }) => { const watermarkSchema = z.object({