diff --git a/src/middleware/db-cache-fallback.ts b/src/middleware/db-cache-fallback.ts index 6677ab3..384591d 100644 --- a/src/middleware/db-cache-fallback.ts +++ b/src/middleware/db-cache-fallback.ts @@ -4,7 +4,7 @@ * When Supabase queries fail, serve stale cached data instead of 500 errors. * This improves availability during DB outages or network issues. */ -import { createLogger, truncateErrorMessage } from "@percolator/shared"; +import { createLogger, truncateErrorMessage, getNetwork } from "@percolator/shared"; import { Context } from "hono"; const logger = createLogger("api:db-cache-fallback"); @@ -53,10 +53,17 @@ export async function withDbCacheFallback( queryFn: () => Promise, c: Context ): Promise | Response> { + // Suffix with the active network so this fallback cache can never serve a + // different network's data than the live query it's backing — every live + // query already filters by network at the DB layer, but the bare cacheKey + // strings callers pass in (e.g. "markets:all") carry no network dimension + // on their own. + const networkedKey = `${cacheKey}:${getNetwork()}`; + try { // Try the query const result = await queryFn(); - + // Evict oldest entries when at capacity while (dbCache.size >= MAX_DB_CACHE_ENTRIES) { const oldest = dbCache.keys().next().value; @@ -65,7 +72,7 @@ export async function withDbCacheFallback( } // Cache successful result - dbCache.set(cacheKey, { + dbCache.set(networkedKey, { data: result, timestamp: Date.now(), }); @@ -76,9 +83,9 @@ export async function withDbCacheFallback( error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120), cacheKey, }); - + // Check if we have cached data - const cached = dbCache.get(cacheKey); + const cached = dbCache.get(networkedKey); if (cached) { const age = Date.now() - cached.timestamp; diff --git a/tests/middleware/db-cache-fallback.test.ts b/tests/middleware/db-cache-fallback.test.ts index 2d01e60..ad19c46 100644 --- a/tests/middleware/db-cache-fallback.test.ts +++ b/tests/middleware/db-cache-fallback.test.ts @@ -9,9 +9,11 @@ vi.mock("@percolator/shared", () => ({ debug: vi.fn(), })), truncateErrorMessage: vi.fn((s: string) => s), + getNetwork: vi.fn(() => "devnet"), })); import { withDbCacheFallback, clearDbCache } from "../../src/middleware/db-cache-fallback.js"; +const { getNetwork } = await import("@percolator/shared"); function makeApp(handler: (c: any) => Promise) { const app = new Hono(); @@ -22,6 +24,7 @@ function makeApp(handler: (c: any) => Promise) { describe("withDbCacheFallback", () => { beforeEach(() => { clearDbCache(); + vi.mocked(getNetwork).mockReturnValue("devnet" as any); }); it("returns DbCacheResult on success with stale=false and ok=true", async () => { @@ -98,4 +101,41 @@ describe("withDbCacheFallback", () => { expect(res.headers.get("X-Cache-Status")).toBe("stale-fallback"); expect(res.headers.get("Warning")).toMatch(/^110 - "Response is Stale/); }); + + it("does not serve a different network's stale cache after the configured network changes (BUG-006)", async () => { + // Seed the cache while serving devnet. + vi.mocked(getNetwork).mockReturnValue("devnet" as any); + const seedApp = makeApp(async (c) => { + const result = await withDbCacheFallback( + "test:network-switch", + async () => ({ network: "devnet-data" }), + c, + ); + if (result instanceof Response) return result; + return c.json(result.data); + }); + const seed = await seedApp.request("/test"); + expect(seed.status).toBe(200); + + // Simulate the deployment's configured network changing (e.g. redeploy/ + // config flip) while the live query for the SAME bare cacheKey fails. + vi.mocked(getNetwork).mockReturnValue("mainnet" as any); + const fallbackApp = makeApp(async (c) => { + const result = await withDbCacheFallback( + "test:network-switch", + async () => { + throw new Error("DB down"); + }, + c, + ); + if (result instanceof Response) return result; + return c.json(result.data); + }); + + const res = await fallbackApp.request("/test"); + // Must NOT silently serve devnet's stale data under the mainnet + // context — there is no mainnet-keyed cache entry, so this must be a + // clean 503, not a 200 carrying the wrong network's data. + expect(res.status).toBe(503); + }); });