fix(api): GET /stats returns 500 whenever sourceId is omitted - #4470
Conversation
/api/stats scopes three of its four queries with `statsSourceId ?? ALL_SOURCES`
but hands the fourth, getMessagesByDayAsync, a bare `statsSourceId`. When the
caller omits sourceId that is `undefined`, which `BaseRepository.withSourceScope`
rejects by design:
withSourceScope: sourceId is required. Pass a concrete sourceId, or the
ALL_SOURCES sentinel for an intentional cross-source query.
So the endpoint throws and returns 500 / STATS_FAILED for every caller that does
not pass sourceId, even though the route comment states the intent explicitly:
"stats totals span all sources when no sourceId is specified". Only the
source-scoped call path worked; the aggregate path was dead.
Why the type system did not catch it: DatabaseService.getMessagesByDayAsync
declared `sourceId?: string`, narrower than the `SourceScope` its own repository
accepts, so passing the ALL_SOURCES symbol would not have compiled. Widening the
service signature to SourceScope makes the correct call expressible and keeps
the service in step with the repository layer.
- route: pass ALL_SOURCES when no sourceId is supplied, matching the sibling calls
- service: widen getMessagesByDayAsync's sourceId to SourceScope
- test: cover the bare /stats call, asserting all four counts receive the
sentinel. The existing test only exercised /stats?sourceId=..., which is why
this went unnoticed.
Verified against a live 4.13.0 instance: GET /api/stats returned 500, and
returns 200 with correct cross-source totals after the change. `tsc --noEmit`
clean; the new test fails without the route fix and passes with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReviewFixes a real, reachable bug with an appropriately minimal diff. I verified the description's claims against the tree rather than taking them on trust:
Diagnosis and fix both look right. Two suggestions1. Hoist the scope. The expression appears four times and the bug is precisely that one copy drifted. One binding makes that impossible: const scope = statsSourceId ?? ALL_SOURCES; // intentional cross-source
const messageCount = await databaseService.messages.getMessageCount(scope);
const nodeCount = await databaseService.nodes.getNodeCount(scope);
const channelCount = await databaseService.channels.getChannelCount(scope);
const messagesByDay = await databaseService.getMessagesByDayAsync(7, scope);2. Make the parameter required rather than just wider. The sibling repo methods take Together these make the bug unrepresentable instead of fixed once. One thing worth confirmingBecause the bare path was dead, this makes cross-source aggregates reachable for the first time. That intent is already established by the three sibling calls and the code comment, so this PR doesn't introduce it. But it's the change that makes it observable, and it deserves a conscious yes rather than arriving as a side effect. TestsGood. The regression test asserts all four counts receive the sentinel, and it failing without the route change with the exact expected diff is the right evidence. On convention: the file uses the RiskLow. No behavior change for callers passing Approve — suggestions 1 and 2 are worth folding in, but neither is blocking. |
…boundary Follow-ups to the fix in this PR, applied per review. Hoist `statsSourceId ?? ALL_SOURCES` into a single `statsScope` binding. The bug being fixed here was one of four copies of that expression drifting from the other three; with one binding that cannot recur. Make `sourceId` REQUIRED on getMessagesByDayAsync and on the repository's getMessagesByDay, matching the sibling count methods (getMessageCount, getNodeCount, getChannelCount) which take `sourceId: SourceScope` — and which is why those three were correct by construction. Widening the type to SourceScope made ALL_SOURCES expressible; leaving the parameter optional kept `undefined` expressible too, so the same omission would still compile and fail at runtime with a 500. Now it is a compile error. The route is the only non-test caller of either method, so this narrows no existing usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9
|
@rancur — I've pushed the two suggestions from my review directly to this branch as 1. Hoisted the scope into a single 2. Made That also brings both in line with the sibling count methods ( Verified on the branch: One thing from my review I did not act on, because it's a judgment call rather than a defect: this PR makes the cross-source aggregate path reachable for the first time, and |
Twelve changes since rc4, across all five version files (package.json, package-lock.json, helm Chart.yaml, desktop/package.json, tauri.conf.json). Highlights: - Remote admin no longer holds an HTTP request across mesh round-trips (#4482 → #4485, #4486): commands, session-passkey acquisition, and remote config import all return 202 + an operation id and complete in the background, fixing upstream 502s behind a reverse proxy. Remote ignore/unignore gained routing-ACK confirmation. - Admin messages now use the node's configured LoRa hop limit instead of a hardcoded 3 (#4479), so nodes further than 3 hops away are administrable. - GET /stats no longer 500s when sourceId is omitted (#4470). - MeshCore fixes: anon auth banner, channel-sync data loss, reply/trigger ordering (#4491), channel view scroll/delete (#4488), hop/route/scope preserved in channel history (#4475). - Navigation unified across Meshtastic/MeshCore per-source views (#4481, #4484). Carries the system-test label: this is the first release build to exercise the async remote-admin paths against real hardware — the Configuration Import leg covers /import-config, which has not run on a device until now. Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The bug
GET /api/statsreturns HTTP 500 /STATS_FAILEDfor any caller that does not passsourceId.routes/dataExchangeRoutes.tsscopes three of its four queries withstatsSourceId ?? ALL_SOURCES, but hands the fourth a barestatsSourceId:With
sourceIdomitted that argument isundefined, whichBaseRepository.withSourceScoperejects by design:The throw is caught by the route's
catchand surfaced as a generic 500, so the endpoint fails with no hint of the cause. The comment immediately above the block states the intended behaviour — "intentional cross-source: stats totals span all sources when no sourceId is specified" — so this is a missed conversion, not a deliberate restriction. Only the?sourceId=...path ever worked; the aggregate path was dead.Why the type checker did not catch it
DatabaseService.getMessagesByDayAsyncdeclaredsourceId?: string, narrower than theSourceScopethat its ownmessagesRepo.getMessagesByDayaccepts. Passing theALL_SOURCESsymbol would not have compiled, so the correct call was not even expressible from the service. Widening the service signature toSourceScopefixes the leaky abstraction and keeps the service in step with the repository layer.Changes
routes/dataExchangeRoutes.ts— passALL_SOURCESwhen nosourceIdis supplied, matching the three sibling calls.services/database.ts— widengetMessagesByDayAsync'ssourceIdtoSourceScope.routes/dataExchangeRoutes.test.ts— regression test for the bare/statscall, asserting all four counts receive the sentinel. The existing test only exercised/stats?sourceId=..., which is why this went unnoticed.Verification
GET /api/stats→ 500STATS_FAILED,GET /api/stats?sourceId=<id>→ 200. After the change, the bare call returns 200 with correct cross-source totals (message/node/channel counts, and a non-emptymessagesByDay).expected "vi.fn()" to be called with arguments: [ 7, Symbol(ALL_SOURCES) ]) and passes with it.npx vitest run src/server/routes/dataExchangeRoutes.test.ts→ 7 passed.npx tsc --noEmit→ clean.No behaviour change for callers that already pass
sourceId.