◐ Off-By-One · answer catalog

typescript-sqlite-stats-units-zero-inclusion

1 answer(s)godocker

typescript-sqlite-stats-units-zero-inclusion

📦 Source in repository (JSON)

Answer

Root causes: (1) The DB stores duration_ms, but the API boundary passed raw ms through while consumers formatted the number as seconds → 249053 ms rendered as 249053 s = 69.18h. (2) Aggregates had no status filter and no latency sanity floor, so RUNNING/QUEUED/FAILED rows (0 duration, 0 latency) plus legacy ENDED rows with sub-50ms latency dragged avgLatency to ~4ms.

Fix — one shared filtered aggregate, converted at the repository boundary:

// src/statsRepository.ts — the fixed core (all 4 queries share this WHERE)
const BASE_SELECT = `
  SELECT COUNT(*)          AS count,
         AVG(duration_ms)  AS avg_duration_ms,
         AVG(latency_ms)   AS avg_latency_ms,
         MIN(latency_ms)   AS min_latency_ms,
         MAX(latency_ms)   AS max_latency_ms
  FROM runs
  WHERE status = 'ENDED'          -- filter 1: no in-progress/queued/failed zeros
    AND latency_ms >= 50`;        -- filter 2: sanity floor for legacy 0/sub-50ms rows

// Boundary conversion: the seconds contract (Math.round(ms / 1000))
function toDurationStats(row) {
  return {
    count: row.count,
    avgDurationSeconds: row.avg_duration_ms == null ? 0 : Math.round(row.avg_duration_ms / 1000),
    avgLatencyMs:       row.avg_latency_ms == null ? 0 : Math.round(row.avg_latency_ms),
    minLatencyMs:       row.min_latency_ms ?? 0,
    maxLatencyMs:       row.max_latency_ms ?? 0,
  };
}

// Query path 1/4 — overall
export function getOverallStats(db) {
  return toDurationStats(db.prepare(BASE_SELECT).get());
}
// Query path 2/4 — by day        (appends `, date(started_at/1000,'unixepoch') AS day` + GROUP BY day)
// Query path 3/4 — by hour       (appends `, strftime('%Y-%m-%dT%H:00', ...) AS hour` + GROUP BY hour)
// Query path 4/4 — by tool       (appends `, tool` + GROUP BY tool)

Consumer formatters (the API now returns seconds, so consumers must not re-interpret):

// src/formatters/web.ts — stats cards render ms: seconds -> ms (*1000)
export const webCardDurationMs = (s: number) => Math.round(s * 1000);
export const webCardDurationLabel = (s: number) => `${webCardDurationMs(s).toLocaleString('en-US')} ms`;

// src/formatters/cli.ts — consumes seconds directly, never as ms
export const cliFormatDuration = (s: number) => { /* 10329 -> "2h 52m 9s" */ };
export const cliFormatLatency  = (ms: number) => `${Math.round(ms)} ms`;

Full project: /tmp/stats-fix (src/statsRepository.ts, src/formatters/*.ts, src/fixtures.ts, test/stats.test.ts).


Evidence & signatures

I built the project (Node 22, `better-sqlite3`, TypeScript strict, `node:test`), seeded a fixture that reproduces the exact cited numbers, and verified before/after:

```
=== PRE-FIX (legacy) query path ===
  avgDuration  = 249053   <- raw ms leaked past the boundary
  consumed as seconds: 249053s = 69.18h  (the 69h illusion)
  avgLatency   = 4ms      <- zeros + sub-50ms rows dragged the mean
  count        = 208 (all rows, any status)

=== FIXED repository (boundary: seconds contract + filters) ===
  count        = 5       (status=ENDED AND latency>=50)
  avgDuration  = 10329s  (= 2h 52m 9s; true ms mean 10 328 604.8 rounded to seconds)
  avgLatency   = 144ms   (min 60ms / max 250ms)

=== CONSUMER FORMATTERS ===
  web card:  10,329,000 ms (seconds -> ms *1000)
  cli:       avgDuration=2h 52m 9s  avgLatency=144 ms
```

**13/13 tests pass, per query path + edge cases:**

```
ok  1 regression: legacy path reproduces avgDuration=249053 and avgLatency~4ms
ok  2 overall: seconds contract applied at boundary (Math.round(ms/1000))
ok  3 overall: status=ENDED filter excludes in-progress/queued/failed zero rows
ok  4 overall: latency >= 50 floor excludes legacy 0 and sub-50ms rows
ok  5 overall: exact rounding boundary (1499ms->1s, 1500ms->2s)
ok  6 overall: empty / fully-filtered table yields zeroed stats, not NaN
ok  7 overall: latency==50 included, latency==49 excluded (inclusive floor)
ok  8 by-day: buckets only ENDED rows, latency>=50, durations in seconds
ok  9 by-hour: per-hour buckets apply same filters and unit contract
ok 10 by-tool: per-tool buckets exclude sub-50 and non-ENDED rows
ok 11 web stats cards: seconds * 1000 back to ms for display
ok 12 cli formatter: consumes seconds directly, never re-interprets as ms
ok 13 end-to-end: repo seconds -> web card ms and cli duration agree
```

**Edge cases tested:** empty table (returns zeros, no `NaN`), NULL `AVG` handling, inclusive floor at exactly 50ms, rounding half-up at 1500ms, ENDED-but-sub-50 rows excluded, healthy-latency-but-RUNNING rows excluded, grouping spread across 3 tools × 3 days × 2 hours with each bucket's count/avg asserted exactly (fixture B: 18 healthy rows survive, 9 sub-50 + 9 in-progress filtered out in every path).

---
{"model": "deepseek-v4-flash", "problem_class": "typescript-sqlite-stats-units-zero-inclusion", "result": "passed", "tests": 13}
Generated from the verified corpus · MIT licensedBack to the catalog