diff --git a/BUY-62788-EVIDENCE-REAPPLY-20260804.md b/BUY-62788-EVIDENCE-REAPPLY-20260804.md new file mode 100644 index 000000000..48864e478 --- /dev/null +++ b/BUY-62788-EVIDENCE-REAPPLY-20260804.md @@ -0,0 +1,84 @@ +# BUY-62788 — SEV-1 Fabricated Cache Fix Re-Application Evidence + +**Date:** 2026-08-04 +**Agent:** Oracle +**Status:** Fix committed, PR #325, MCP test running + +## Original Issue (2026-07-16) + +MCP `search_products` in browse mode (no `q`) with `country_code` filter returned: +- `meta.total = 288024448` (global `pg_class.reltuples` ≈ 288M) +- `data: []` (empty results) + +Root cause: browse path fetched recent global rows via `idx_products_updated_at`, then filtered `country_code` in-application. Recent rows were predominantly US/null, so SG/filtered-US pages returned empty while `total` stayed at the inflated global estimate. + +## Fix Applied (2026-07-17) + +Commit `2ee6f65fd` — pushed `is_active + country_code + region` into SQL WHERE and replaced global reltuples with bounded `COUNT_CEIL=100000`. + +## Regression (2026-07-29) + +Commit `b5781c9cd` (BUY-65298) rewrote `mcp.ts` with -303/+199 lines, inadvertently reverting the BUY-62788 fix. The browse-mode code reverted to: +- `SELECT reltuples::bigint FROM pg_class` (global ~303M total) +- In-app filtering of global recent rows + +## Current Live State (2026-08-04 — before this fix) + +``` +search_products {country_code:'SG', limit:3} → data:3, total:303832896 ← WRONG +search_products {country_code:'US', limit:3} → data:3, total:303832896 ← WRONG +``` + +Results happen to appear because recent ingestion includes SG/US products. But `meta.total` is inflated, and the SEV-1 will resurface when recent rows don't match the country filter. + +## Fix Re-Applied (2026-08-04) + +**Commit:** `cb406888a` on branch `fix/BUY-62788-mcp-browse-fabricated-cache-reapply` +**PR:** #325 + +### What Changed + +`api/src/routes/mcp.ts` — browse mode block (lines 415–459): + +**Before (regressed):** +```typescript +// No FTS — browse mode. Use reltuples for approximate total +const approxResult = await searchClient.query( + `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'products'` +); +total = parseInt(approxResult.rows[0]?.estimate ?? '0', 10); +// Fetch global rows, filter in-app +``` + +**After (fixed):** +```typescript +// No FTS — browse mode. Push is_active + country_code + region into SQL +const browseCond: string[] = ['is_active = true']; +if (country) { browseCond.push(`country_code = $${browseParams.length}`); } +if (region) { browseCond.push(`lower(region) = $${browseParams.length}`); } +const browseWhere = `WHERE ${browseCond.join(' AND ')}`; +// SQL-filtered fetch with proper LIMIT/OFFSET ::int casts +// Bounded COUNT with COUNT_CEIL=100000 +const countRes = await searchClient.query( + `SELECT COUNT(*)::bigint AS n FROM ( + SELECT 1 FROM products ${browseWhere} LIMIT $${countParams.length}::int + ) _c`, + countParams +); +total = parseInt(countRes.rows[0]?.n ?? '0', 10); +``` + +## Verification Plan + +1. **MCP Continuous Testing** — running now on PR #325 +2. **Live probe after deploy:** + - `search_products {country_code:'SG', limit:3}` → `meta.total` ≤ 100000 + - `search_products {country_code:'US', limit:3}` → `meta.total` ≤ 100000 +3. **FTS path unchanged:** `search_products {q:'laptop', country_code:'SG'}` still works + +## Related Issues + +- BUY-62677 — SEV-1 gateway dispatch (in_review) +- BUY-62605 — SG get_deals unavailable (todo) +- BUY-60528 — list_categories stale counts (backlog) +- BUY-65298 — reverted this fix (b5781c9cd) diff --git a/api/src/routes/mcp.ts b/api/src/routes/mcp.ts index f24b6bca5..766a3447b 100644 --- a/api/src/routes/mcp.ts +++ b/api/src/routes/mcp.ts @@ -413,38 +413,49 @@ async function handleSearchProducts(args: Record) { rows = result.rows; } } else { - // No FTS — browse mode. Use reltuples for approximate total and fetch - // recent products via idx_products_updated_at (3ms for 500 rows). - // If user explicitly passed country_code/region, overfetch and filter - // in-application (no composite index on country_code+updated_at). - const approxResult = await searchClient.query( - `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'products'` - ); - total = parseInt(approxResult.rows[0]?.estimate ?? '0', 10); + // No FTS — browse mode. Push is_active + country_code + region into SQL + // (BUY-62788 fix: re-applied) so the page and total reflect the filtered set, + // instead of fetching recent global rows and filtering in-app (which returned 0 rows + // for country filters while total showed the global reltuples ~288M). + const browseCond: string[] = ['is_active = true']; + const browseParams: unknown[] = []; + if (country) { + browseParams.push(country.toUpperCase()); + browseCond.push(`country_code = $${browseParams.length}`); + } + if (region) { + browseParams.push(region.toLowerCase()); + browseCond.push(`lower(region) = $${browseParams.length}`); + } + const browseWhere = `WHERE ${browseCond.join(' AND ')}`; - const needsFilter = !!(country || region); - const fetchLimit = needsFilter ? Math.min((limit + offset) * 20, 5000) : limit + offset; + // Fetch filtered rows with proper SQL WHERE + const pageParams = [...browseParams, limit, offset]; const rawResult = await searchClient.query( `SELECT id, sku AS source, source AS domain, url, title, price, currency, image_url, metadata, updated_at, region, country_code - FROM products + FROM products ${browseWhere} ORDER BY updated_at DESC - LIMIT $1::int`, - [fetchLimit] + LIMIT $${pageParams.length - 1}::int OFFSET $${pageParams.length}::int`, + pageParams ); - if (needsFilter) { - let filtered = rawResult.rows as Record[]; - if (country) { - filtered = filtered.filter(r => (r.country_code as string || '').toUpperCase() === country); - } - if (region) { - filtered = filtered.filter(r => (r.region as string || '').toLowerCase() === region.toLowerCase()); - } - rows = filtered.slice(offset, offset + limit); - } else { - rows = (rawResult.rows as unknown[]).slice(offset, offset + limit); - } + rows = rawResult.rows as Record[]; + + // Bounded count on the filtered set. With a country/region filter this is + // cheap (country_code / is_active indexes); with no filter we cap it so we + // never run an exact count(*) over the whole ~288M-row table (constraint + // BUY-59936 #6). Replaces the global reltuples estimate that + // made empty country-filtered pages look like a 288M-row "fabricated cache". + const COUNT_CEIL = 100000; + const countParams = [...browseParams, COUNT_CEIL]; + const countRes = await searchClient.query( + `SELECT COUNT(*)::bigint AS n FROM ( + SELECT 1 FROM products ${browseWhere} LIMIT $${countParams.length}::int + ) _c`, + countParams + ); + total = parseInt(countRes.rows[0]?.n ?? '0', 10); } } finally { // BUY-56185: always use safe release to discard connections poisoned by statement_timeout