diff --git a/src/routes/crank.ts b/src/routes/crank.ts index ff1a29f..75065f7 100644 --- a/src/routes/crank.ts +++ b/src/routes/crank.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import { getSupabase, getNetwork, createLogger, truncateErrorMessage } from "@percolator/shared"; +import { withDbCacheFallback } from "../middleware/db-cache-fallback.js"; const logger = createLogger("api:crank"); @@ -7,20 +8,25 @@ export function crankStatusRoutes(): Hono { const app = new Hono(); app.get("/crank/status", async (c) => { - try { - const { data, error } = await getSupabase() - .from("markets_with_stats") - .select("slab_address, last_crank_slot, updated_at") - .eq("network", getNetwork()) - .not("slab_address", "is", null); - if (error) throw error; - return c.json({ markets: data ?? [] }); - } catch (err) { - logger.error("Error fetching crank status", { - error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120), - }); - return c.json({ error: "Failed to fetch crank status" }, 500); + const result = await withDbCacheFallback( + "crank:status", + async () => { + const { data, error } = await getSupabase() + .from("markets_with_stats") + .select("slab_address, last_crank_slot, updated_at") + .eq("network", getNetwork()) + .not("slab_address", "is", null); + if (error) throw error; + return data ?? []; + }, + c + ); + + if (result instanceof Response) { + return result; } + + return c.json({ markets: result }); }); return app; diff --git a/src/routes/markets.ts b/src/routes/markets.ts index fa4703a..6bf4351 100644 --- a/src/routes/markets.ts +++ b/src/routes/markets.ts @@ -80,20 +80,25 @@ export function marketRoutes(): Hono { // GET /markets/stats — all market stats from DB (filtered by network) app.get("/markets/stats", async (c) => { - try { - const { data, error } = await getSupabase() - .from("markets_with_stats") - .select("slab_address, total_open_interest, total_accounts, last_crank_slot, last_price, mark_price, index_price, funding_rate, net_lp_pos, updated_at") - .eq("network", getNetwork()) - .not("slab_address", "is", null); - if (error) throw error; - return c.json({ stats: data ?? [] }); - } catch (err) { - logger.error("Error fetching all market stats", { - error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120), - }); - return c.json({ error: "Failed to fetch market stats" }, 500); + const result = await withDbCacheFallback( + "markets:stats", + async () => { + const { data, error } = await getSupabase() + .from("markets_with_stats") + .select("slab_address, total_open_interest, total_accounts, last_crank_slot, last_price, mark_price, index_price, funding_rate, net_lp_pos, updated_at") + .eq("network", getNetwork()) + .not("slab_address", "is", null); + if (error) throw error; + return data ?? []; + }, + c + ); + + if (result instanceof Response) { + return result; } + + return c.json({ stats: result }); }); // GET /markets/:slab/stats — single market stats from DB diff --git a/src/routes/prices.ts b/src/routes/prices.ts index 2c666ee..c22291e 100644 --- a/src/routes/prices.ts +++ b/src/routes/prices.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { getSupabase, getNetwork, createLogger, truncateErrorMessage } from "@percolator/shared"; import { validateSlab } from "../middleware/validateSlab.js"; +import { withDbCacheFallback } from "../middleware/db-cache-fallback.js"; const logger = createLogger("api:prices"); @@ -8,20 +9,25 @@ export function priceRoutes(): Hono { const app = new Hono(); app.get("/prices/markets", async (c) => { - try { - const { data, error } = await getSupabase() - .from("markets_with_stats") - .select("slab_address, last_price, mark_price, index_price, updated_at") - .eq("network", getNetwork()) - .not("slab_address", "is", null); - if (error) throw error; - return c.json({ markets: data ?? [] }); - } catch (err) { - logger.error("Error fetching market prices", { - error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120), - }); - return c.json({ error: "Failed to fetch prices" }, 500); + const result = await withDbCacheFallback( + "prices:markets", + async () => { + const { data, error } = await getSupabase() + .from("markets_with_stats") + .select("slab_address, last_price, mark_price, index_price, updated_at") + .eq("network", getNetwork()) + .not("slab_address", "is", null); + if (error) throw error; + return data ?? []; + }, + c + ); + + if (result instanceof Response) { + return result; } + + return c.json({ markets: result }); }); app.get("/prices/:slab", validateSlab, async (c) => { diff --git a/src/routes/stats.ts b/src/routes/stats.ts index aaee4fc..d900ea1 100644 --- a/src/routes/stats.ts +++ b/src/routes/stats.ts @@ -10,6 +10,7 @@ */ import { Hono } from "hono"; import { getSupabase, getNetwork, createLogger, truncateErrorMessage } from "@percolator/shared"; +import { withDbCacheFallback } from "../middleware/db-cache-fallback.js"; const logger = createLogger("api:stats"); @@ -31,69 +32,73 @@ export function statsRoutes(): Hono { * } */ app.get("/stats", async (c) => { - try { - const network = getNetwork(); - - // Count total markets — filter by network to prevent devnet/mainnet mixing (PERC-8192) - const { count: marketsCount, error: marketsError } = await getSupabase() - .from("markets") - .select("*", { count: "exact", head: true }) - .eq("network", network); - - if (marketsError) throw marketsError; - - // Aggregate stats from markets_with_stats view — filter by network to - // prevent cross-network volume/OI inflation in shared DB deployments. - const { data: stats, error: statsError } = await getSupabase() - .from("markets_with_stats") - .select("volume_24h, total_open_interest") - .eq("network", network) - .not("slab_address", "is", null); - - if (statsError) throw statsError; - - const safeBigInt = (val: unknown): bigint => { - try { return BigInt(val as string); } catch { return 0n; } - }; - const volume24h = (stats ?? []).reduce((sum, s) => sum + safeBigInt(s.volume_24h ?? "0"), 0n); - const totalOI = (stats ?? []).reduce((sum, s) => sum + safeBigInt(s.total_open_interest ?? "0"), 0n); - - // Count unique deployers — filter by network - const { data: deployers, error: deployersError } = await getSupabase() - .from("markets") - .select("deployer") - .eq("network", network); - - if (deployersError) throw deployersError; - - const uniqueDeployers = new Set((deployers ?? []).map((d) => d.deployer)).size; - - // Count 24h trades — filter by network - // NOTE: trades table uses `created_at` (TIMESTAMPTZ), not `timestamp`. - // The `timestamp` column exists only on oracle_prices/funding_history/oi_history. - const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - const { count: trades24h, error: tradesError } = await getSupabase() - .from("trades") - .select("*", { count: "exact", head: true }) - .eq("network", network) - .gte("created_at", since24h); - - if (tradesError) throw tradesError; - - return c.json({ - totalMarkets: marketsCount ?? 0, - volume24h: volume24h.toString(), - totalOpenInterest: totalOI.toString(), - uniqueDeployers, - trades24h: trades24h ?? 0, - }); - } catch (err) { - logger.error("Error fetching platform stats", { error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120) }); - return c.json({ - error: "Failed to fetch platform statistics", - ...(process.env.NODE_ENV !== "production" && { details: err instanceof Error ? err.message : String(err) }) - }, 500); + const result = await withDbCacheFallback( + "stats:platform", + async () => { + const network = getNetwork(); + + // Count total markets — filter by network to prevent devnet/mainnet mixing (PERC-8192) + const { count: marketsCount, error: marketsError } = await getSupabase() + .from("markets") + .select("*", { count: "exact", head: true }) + .eq("network", network); + + if (marketsError) throw marketsError; + + // Aggregate stats from markets_with_stats view — filter by network to + // prevent cross-network volume/OI inflation in shared DB deployments. + const { data: stats, error: statsError } = await getSupabase() + .from("markets_with_stats") + .select("volume_24h, total_open_interest") + .eq("network", network) + .not("slab_address", "is", null); + + if (statsError) throw statsError; + + const safeBigInt = (val: unknown): bigint => { + try { return BigInt(val as string); } catch { return 0n; } + }; + const volume24h = (stats ?? []).reduce((sum, s) => sum + safeBigInt(s.volume_24h ?? "0"), 0n); + const totalOI = (stats ?? []).reduce((sum, s) => sum + safeBigInt(s.total_open_interest ?? "0"), 0n); + + // Count unique deployers — filter by network + const { data: deployers, error: deployersError } = await getSupabase() + .from("markets") + .select("deployer") + .eq("network", network); + + if (deployersError) throw deployersError; + + const uniqueDeployers = new Set((deployers ?? []).map((d) => d.deployer)).size; + + // Count 24h trades — filter by network + // NOTE: trades table uses `created_at` (TIMESTAMPTZ), not `timestamp`. + // The `timestamp` column exists only on oracle_prices/funding_history/oi_history. + const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const { count: trades24h, error: tradesError } = await getSupabase() + .from("trades") + .select("*", { count: "exact", head: true }) + .eq("network", network) + .gte("created_at", since24h); + + if (tradesError) throw tradesError; + + return { + totalMarkets: marketsCount ?? 0, + volume24h: volume24h.toString(), + totalOpenInterest: totalOI.toString(), + uniqueDeployers, + trades24h: trades24h ?? 0, + }; + }, + c + ); + + if (result instanceof Response) { + return result; } + + return c.json(result); }); return app; diff --git a/tests/routes/crank.test.ts b/tests/routes/crank.test.ts index 878e071..9568283 100644 --- a/tests/routes/crank.test.ts +++ b/tests/routes/crank.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { crankStatusRoutes } from "../../src/routes/crank.js"; +import { clearDbCache } from "../../src/middleware/db-cache-fallback.js"; // Mock @percolator/shared vi.mock("@percolator/shared", () => ({ @@ -45,6 +46,7 @@ describe("crank routes", () => { beforeEach(() => { vi.clearAllMocks(); + clearDbCache(); mockSupabase = { from: vi.fn(() => chainable({ data: [], error: null })), @@ -112,7 +114,7 @@ describe("crank routes", () => { expect(data.markets[0].updated_at).toBeNull(); }); - it("should handle database errors", async () => { + it("should handle database errors with 503 (no stale cache)", async () => { mockSupabase.from.mockReturnValue(chainable({ data: null, error: new Error("Database error"), @@ -121,9 +123,10 @@ describe("crank routes", () => { const app = crankStatusRoutes(); const res = await app.request("/crank/status"); - expect(res.status).toBe(500); + // withDbCacheFallback returns 503 when DB fails and no stale cache is available + expect(res.status).toBe(503); const data = await res.json(); - expect(data.error).toBe("Failed to fetch crank status"); + expect(data.error).toBe("Database temporarily unavailable"); }); it("should return all market stats fields", async () => { diff --git a/tests/routes/prices.test.ts b/tests/routes/prices.test.ts index ac30a5e..d80885c 100644 --- a/tests/routes/prices.test.ts +++ b/tests/routes/prices.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { priceRoutes } from "../../src/routes/prices.js"; +import { clearDbCache } from "../../src/middleware/db-cache-fallback.js"; // Mock @percolator/shared vi.mock("@percolator/shared", () => ({ @@ -30,6 +31,7 @@ describe("prices routes", () => { beforeEach(() => { vi.clearAllMocks(); + clearDbCache(); mockSupabase = { from: vi.fn(() => mockSupabase), @@ -73,18 +75,19 @@ describe("prices routes", () => { expect(data.markets[0].slab_address).toBe("11111111111111111111111111111111"); }); - it("should handle database errors", async () => { - mockSupabase.not.mockResolvedValue({ - data: null, - error: new Error("Database error") + it("should handle database errors with 503 (no stale cache)", async () => { + mockSupabase.not.mockResolvedValue({ + data: null, + error: new Error("Database error") }); const app = priceRoutes(); const res = await app.request("/prices/markets"); - expect(res.status).toBe(500); + // withDbCacheFallback returns 503 when DB fails and no stale cache is available + expect(res.status).toBe(503); const data = await res.json(); - expect(data.error).toBe("Failed to fetch prices"); + expect(data.error).toBe("Database temporarily unavailable"); }); it("should handle empty markets list", async () => { @@ -122,8 +125,8 @@ describe("prices routes", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.prices).toHaveLength(2); - expect(mockSupabase.order).toHaveBeenCalledWith("timestamp", { ascending: false }); - expect(mockSupabase.limit).toHaveBeenCalledWith(100); + expect(mockSupabase.order).toHaveBeenCalledWith("timestamp", { ascending: true }); + expect(mockSupabase.limit).toHaveBeenCalledWith(1500); }); it("should return 400 for invalid slab", async () => { diff --git a/tests/routes/stats.test.ts b/tests/routes/stats.test.ts index ae55a5d..8140f2e 100644 --- a/tests/routes/stats.test.ts +++ b/tests/routes/stats.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { statsRoutes } from "../../src/routes/stats.js"; +import { clearDbCache } from "../../src/middleware/db-cache-fallback.js"; // Mock @percolator/shared vi.mock("@percolator/shared", () => ({ @@ -48,6 +49,7 @@ describe("stats routes", () => { beforeEach(() => { vi.clearAllMocks(); + clearDbCache(); // Create a chainable mock that supports the full Supabase query builder pattern. // All filter/modifier methods return `mockSupabase` to allow arbitrary chaining. @@ -324,7 +326,7 @@ describe("stats routes", () => { expect(typeof data.trades24h).toBe("number"); }); - it("should handle database errors", async () => { + it("should handle database errors with 503 (no stale cache)", async () => { mockSupabase.from.mockImplementation((_table: string) => { return chainable({ count: null, data: null, error: new Error("Database error") }); }); @@ -332,9 +334,10 @@ describe("stats routes", () => { const app = statsRoutes(); const res = await app.request("/stats"); - expect(res.status).toBe(500); + // withDbCacheFallback returns 503 when DB fails and no stale cache is available + expect(res.status).toBe(503); const data = await res.json(); - expect(data.error).toBe("Failed to fetch platform statistics"); + expect(data.error).toBe("Database temporarily unavailable"); }); }); });