{"record":{"id":"f577d46892800d14","repo":"koala73/worldmonitor","slug":"failed-to-build-movers-snapshot","errorCode":null,"errorMessage":"failed to build movers snapshot","messagePattern":"failed to build movers snapshot","errorType":"http","errorClass":null,"httpStatus":500,"severity":"error","filePath":"consumer-prices-core/src/api/routes/worldmonitor.ts","lineNumber":42,"sourceCode":"  fastify.get('/overview', async (request, reply) => {\n    const { market = 'ae' } = request.query as { market?: string };\n    try {\n      const data = await buildOverviewSnapshot(market);\n      return reply.send(data);\n    } catch (err) {\n      fastify.log.error(err);\n      return reply.status(500).send({ error: 'failed to build overview snapshot' });\n    }\n  });\n\n  fastify.get('/movers', async (request, reply) => {\n    const { market = 'ae', days = '30' } = request.query as { market?: string; days?: string };\n    try {\n      const data = await buildMoversSnapshot(market, parseInt(days, 10));\n      return reply.send(data);\n    } catch (err) {\n      fastify.log.error(err);\n      return reply.status(500).send({ error: 'failed to build movers snapshot' });\n    }\n  });\n\n  fastify.get('/retailer-spread', async (request, reply) => {\n    const { market = 'ae', basket = 'essentials-ae' } = request.query as {\n      market?: string;\n      basket?: string;\n    };\n    try {\n      const data = await buildRetailerSpreadSnapshot(market, basket);\n      return reply.send(data);\n    } catch (err) {\n      fastify.log.error(err);\n      return reply.status(500).send({ error: 'failed to build retailer spread snapshot' });\n    }\n  });\n\n  fastify.get('/freshness', async (request, reply) => {","sourceCodeStart":24,"sourceCodeEnd":60,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/consumer-prices-core/src/api/routes/worldmonitor.ts#L24-L60","documentation":"The Fastify route GET /worldmonitor/movers returned 500 because buildMoversSnapshot(market, days) threw. Two inputs feed the builder: the market code (default 'ae') and days (default '30'), parsed with parseInt(days, 10) — a non-numeric days yields NaN that flows into the snapshot query. The rest of the failure surface is the same as the other snapshot routes: the pg pool in src/db/client.ts throwing on missing DATABASE_URL, connection failure, or a SQL regression.","triggerScenarios":"Calling /movers?days=abc so parseInt returns NaN and the builder receives an invalid range; DATABASE_URL unset; Postgres unreachable; a SQL regression in the movers query; a market with no price observations in the window hitting a throwing expectation.","commonSituations":"Client passing days as an empty string or typo'd value ('30d' instead of '30'); deployment missing the DB secret; the movers window extending before any collected data.","solutions":["Validate days client-side: it must parse as a positive integer before calling the route","Check the fastify error log for the underlying exception","Verify DATABASE_URL and database reachability","Reproduce with buildMoversSnapshot(market, n) using the exact values to isolate SQL vs parameter issues"],"exampleFix":"// before — NaN flows into the snapshot builder\nconst data = await buildMoversSnapshot(market, parseInt(days, 10));\n\n// after — reject bad input before it reaches SQL\nconst n = parseInt(days, 10);\nif (!Number.isInteger(n) || n < 1) {\n  return reply.status(400).send({ error: 'days must be a positive integer' });\n}\nconst data = await buildMoversSnapshot(market, n);","handlingStrategy":"validation","validationCode":"const raw = params.get('days') ?? '30';\nconst days = Number.parseInt(raw, 10);\nif (!Number.isInteger(days) || days < 1 || days > 365) {\n  throw new Error(`invalid days '${raw}' — must be an integer 1-365`);\n}\nconst res = await fetch(`/worldmonitor/movers?market=ae&days=${days}`);","typeGuard":"function isValidDays(v: unknown): v is number {\n  return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 365;\n}","tryCatchPattern":"try {\n  const res = await fetch(`/worldmonitor/movers?market=${market}&days=${days}`);\n  if (!res.ok) throw new Error(`movers: HTTP ${res.status}`);\n  return await res.json();\n} catch (err) {\n  if (/HTTP 500/.test(String(err))) return readCachedMovers(market, days); // fall back to last good snapshot\n  throw err;\n}","preventionTips":["Never pass a raw query string into days — parseInt('30d') is NaN and flows straight into the SQL layer","Bound the window (1-365) client-side so the builder never sees extreme ranges","Distinguish 400 (param bug) from 500 (backend) in clients; only 500s merit retry"],"tags":["fastify","http-500","database","snapshot","query-params"],"backgroundTag":"database-query-failed","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-08-31T04:17:50.494Z"}