Skip to content

fix(api): GET /stats returns 500 whenever sourceId is omitted - #4470

Open
rancur wants to merge 1 commit into
Yeraze:mainfrom
rancur:fix/stats-cross-source-sentinel
Open

fix(api): GET /stats returns 500 whenever sourceId is omitted#4470
rancur wants to merge 1 commit into
Yeraze:mainfrom
rancur:fix/stats-cross-source-sentinel

Conversation

@rancur

@rancur rancur commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The bug

GET /api/stats returns HTTP 500 / STATS_FAILED for any caller that does not pass sourceId.

routes/dataExchangeRoutes.ts scopes three of its four queries with statsSourceId ?? ALL_SOURCES, but hands the fourth a bare statsSourceId:

const messageCount  = await databaseService.messages.getMessageCount(statsSourceId ?? ALL_SOURCES);
const nodeCount     = await databaseService.nodes.getNodeCount(statsSourceId ?? ALL_SOURCES);
const channelCount  = await databaseService.channels.getChannelCount(statsSourceId ?? ALL_SOURCES);
const messagesByDay = await databaseService.getMessagesByDayAsync(7, statsSourceId);   // <-- undefined

With sourceId omitted that argument 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. Omitting sourceId
used to silently return rows from every source (data leak).

The throw is caught by the route's catch and 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.getMessagesByDayAsync declared sourceId?: string, narrower than the SourceScope that its own messagesRepo.getMessagesByDay accepts. Passing the ALL_SOURCES symbol would not have compiled, so the correct call was not even expressible from the service. Widening the service signature to SourceScope fixes the leaky abstraction and keeps the service in step with the repository layer.

Changes

  • routes/dataExchangeRoutes.ts — pass ALL_SOURCES when no sourceId is supplied, matching the three sibling calls.
  • services/database.ts — widen getMessagesByDayAsync's sourceId to SourceScope.
  • routes/dataExchangeRoutes.test.ts — regression test for the bare /stats call, asserting all four counts receive the sentinel. The existing test only exercised /stats?sourceId=..., which is why this went unnoticed.

Verification

  • Reproduced on a live 4.13.0 deployment: GET /api/stats → 500 STATS_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-empty messagesByDay).
  • The new test fails without the route change (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.

/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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant