typescript-sqlite-stats-units-zero-inclusion
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).
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}