◐ Off-By-One · answer catalog

js-frontend-inert-filter-threading

1 answer(s)godocker

js-frontend-inert-filter-threading

📦 Source in repository (JSON)

Answer

The bug: the change listener fired refreshAll(), but the select's value was never read, so getDailyData/renderChart/renderTable ran against unfiltered data — every key produced identical output.

The fix, in four moves:

1. Read the value in the refresh entry point and thread it through every function.

// BEFORE (inert)
select.addEventListener('change', refreshAll);
function refreshAll() {
  const daily = getDailyData();          // value never read
  renderKPIs(daily); renderChart(daily); renderTable(daily);
}

// AFTER
select.addEventListener('change', () => refreshAll(select.value));

function refreshAll(apiKeyName) {
  const daily = getDailyData(apiKeyName);   // threaded as a parameter
  renderKPIs(daily, apiKeyName);
  renderChart(daily, apiKeyName);
  renderTable(daily, apiKeyName);
}

2. Append WHERE col = ? only to queries touching the table that has the column — and use a dedicated cdWhere/cdParams split for cost_daily, which has no api_key_name column. Leaking the filter there causes a binder error.

function getDailyData(apiKeyName) {
  // api_usage HAS api_key_name → filter here, via parameterized ?-param
  const where = [], params = [];
  if (apiKeyName) { where.push('api_key_name = ?'); params.push(apiKeyName); }

  const apiSql = 'SELECT date, api_key_name, SUM(cost) AS cost FROM api_usage'
               + (where.length ? ' WHERE ' + where.join(' AND ') : '')
               + ' GROUP BY date, api_key_name';

  // cost_daily LACKS api_key_name → its own where/params, always empty.
  // Nothing from the filter may ever touch this query.
  const cdSql    = 'SELECT date, SUM(cost) AS cost FROM cost_daily GROUP BY date';
  const cdParams = [];

  return db.exec(apiSql, params).concat(db.exec(cdSql, cdParams));
}

3. Preserve the select value across innerHTML rebuilds — snapshot before repopulating options, restore after:

function rebuildKeyOptions(keys) {
  const select = document.getElementById('apiKeyFilter');
  const prev = select.value;                       // snapshot
  select.innerHTML =
    '<option value="">All keys</option>' +
    keys.map(k => `<option value="${k}">${k}</option>`).join('');
  select.value = prev;                             // restore
}

4. Unit test — stub db.exec with canned rows per key; assert the totals differ and the SQL carries the filter; guard with a leakage test on cost_daily:

const calls = [];
D.setDb({
  exec(sql, params) {
    calls.push({ sql, params });
    if (sql.includes('cost_daily')) return cdRows;         // never filtered
    return rowsByKey[params[0]] || [];                     // canned per key
  },
});

// regression: costA !== costB
const costA = getDailyData('keyA').reduce((s,r)=>s+r.cost,0);
const costB = getDailyData('keyB').reduce((s,r)=>s+r.cost,0);
assert(costA !== costB);

// SQL carries the filter
assert(calls.some(c => c.sql.includes('api_key_name = ?') && c.params[0] === 'keyB'));

// leakage guard: cost_daily never references the column
const cd = calls.find(c => c.sql.includes('cost_daily'));
assert(!/api_key_name/i.test(cd.sql) && cd.params.length === 0);

Evidence & signatures

Verified by running the full harness (`node dashboard.test.js` in `/tmp/dashboard-fix`): **7/7 passing.**

- **Regression (inert filter):** with `keyA → cost 150`, `keyB → cost 700` (plus shared `cost_daily` row), totals are `160` vs `710` — `costA !== costB` proves the value is now consumed.
- **SQL threading:** call log shows `api_key_name = ?` with `params[0] === 'keyB'` on the filtered query.
- **Leakage guard:** regex over the recorded `cost_daily` SQL confirms it never contains `api_key_name`, and its params array stays empty — the binder error the `cdWhere/cdParams` split exists to avoid is impossible.
- **Edge cases tested:**
  - *Empty selection* (`''`): no `WHERE` clause emitted at all, no dangling `?` binder.
  - *Key with no rows*: stub returns `[]`, totals compute to 0, no crash.
  - *Multiple WHERE conditions*: built via array `join(' AND ')`, so future filters compose safely.
  - *`innerHTML` rebuild*: DOM shim asserts `select.value` survives option repopulation (`keyA` retained).
  - *End-to-end*: `refreshAll('keyB')` with a DOM shim updates KPI text, table rows, and fires the filtered query — the value reaches all three render paths.
{"model": "deepseek-v4-flash", "problem_class": "js-frontend-inert-filter-threading", "result": "passed", "tests": 7}
Generated from the verified corpus · MIT licensedBack to the catalog