Files
ersatztv/docs/decisions/records/api/paged-count-matches-page-query.md
T
timothyandtimothy 08cd3a002d
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m40s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m46s
fix(690,758): count the same query a paged handler pages (#833)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 07:39:23 +00:00

12 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
api.paged-count-matches-page-query 2026-08-26 — A paged total is computed from the SAME query it pages — one IQueryable in a handler, a test-pinned pair where the count and page are separate methods (#690, #758) active 2026-08-26 none none A handler that returns a page plus a total count builds ONE `IQueryable`, applies every filter to it, and then derives BOTH the count and the page from that single object — `int count = await query.CountAsync(ct)` followed by `query.Include(...).OrderBy(...).Skip(...).Take(...)`. Counting the `DbSet` directly, or re-stating the predicate in a second `CountAsync(pred, ct)`, is the defect: the two expressions are then free to drift and nothing reports it. This is not a style preference — the drifted state is SILENT and shaped like working software. The page is correct, the count is wrong, and the client trusts the count: the SPA paginates on `TotalCount`, so 40 rows with 3 matching a search renders 4 pages of which 3 are permanently empty (#690), and an MCP agent paging to a completeness target reads a `totalCount` its own page can never reach (#758). Scope that harm honestly — of the six, only `GetPagedRerunCollections`, `GetPagedMultiCollections` and `GetPagedPlayouts` reach a controller today; `GetPagedCollections`, `GetPagedSmartCollections` and `GetPagedProgramSchedules` have no production caller (their REST routes use unpaged `GetAll*` queries), so they were latent, not live. Both named issues were ONE mechanism at six sites, of which the issues named two: `GetPagedCollections`, `GetPagedMultiCollections`, `GetPagedRerunCollections`, `GetPagedSmartCollections`, `GetPagedPlayouts`, `GetPagedProgramSchedules`. THE FILTER IS NOT ONLY THE SEARCH STRING — `GetPagedPlayouts` also applies `Filter(p => p.Channel != null)` to the page, and counting the DbSet missed that too; that clause is DEFENSIVE rather than a live defect, because `Playout.ChannelId` is non-nullable with `DeleteBehavior.Cascade` and both production connection strings set `foreign keys=true`, so the orphan state is unreachable while the FK holds. Three corollaries. (1) INCLUDES BELONG TO THE PAGE CHAIN, not to the shared filtered query: a COUNT does not materialize the graph, so `.Include(...)`/`IncludeSelectionDetails()` are appended after the count is taken, which keeps `api.selection-projection-include-chain` intact while leaving one predicate source. (2) A HANDLER WITH NO FILTER STILL TAKES THE SHAPE — `GetPagedFillerPresets` and `GetPagedTraktLists` take no `Query` parameter, so their `DbSet` counts were not WRONG, but leaving them counting one expression while paging another preserves exactly the drift this record is about for whoever adds the first filter. They derive both from one query too. (3) THE POPULATION IS DERIVED FROM THE SHAPE, NOT FROM THE `GetPaged*` NAME — three further count+page producers in `ErsatzTV.Application/MediaCards` (`GetTelevisionSeasonCards`, `GetTelevisionEpisodeCards`, `GetMusicVideoCards`) carry the same drift across a REPOSITORY boundary, where the count and the page are two interface methods rather than two expressions, so the structural fix cannot apply and they are pinned by a test instead (`MediaCardsCountMatchesPageTests`). `GetSeasonCount` now expands to the same Title+Year show set `GetPagedSeasons` pages; `GetEpisodeCount` and `GetMusicVideoCount` now count the METADATA table their pages are taken from, so a media item whose metadata row is missing no longer inflates the total. Their 1-based `pageNumber` is a separate defect against `api.paging-zero-based` and stays open in #832. TotalCount ignores the search query · filtered page reports the unfiltered total · SPA renders empty pages after a search · agent pages to a completeness target it can never reach · count the same query you page · one predicate applied to both so they cannot drift · CountAsync on the DbSet · Channel != null missing from the count · includes belong to the page chain not the counted query · an unfiltered paged handler takes the shape too · a repository count and its page are two methods that must be pinned by a test · paths: `ErsatzTV.Application/MediaCollections/Queries/GetPagedCollectionsHandler.cs`, `ErsatzTV.Application/MediaCollections/Queries/GetPagedMultiCollectionsHandler.cs`, `ErsatzTV.Application/MediaCollections/Queries/GetPagedRerunCollectionsHandler.cs`, `ErsatzTV.Application/MediaCollections/Queries/GetPagedSmartCollectionsHandler.cs`, `ErsatzTV.Application/Playouts/Queries/GetPagedPlayoutsHandler.cs`, `ErsatzTV.Application/ProgramSchedules/Queries/GetPagedProgramSchedulesHandler.cs`, `ErsatzTV.Application/Filler/Queries/GetPagedFillerPresetsHandler.cs`, `ErsatzTV.Application/MediaCollections/Queries/GetPagedTraktListsHandler.cs`, `ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs`, `ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs`, `ErsatzTV.Tests/Application/Paging/PagedQueryTotalCountTests.cs`, `ErsatzTV.Tests/Application/Paging/MediaCardsCountMatchesPageTests.cs` · issues: #758, #690, #671, #757 TWO enforcement modes, because the rule has two shapes. Where the count and the page are expressions in one handler, the STRUCTURE carries it (one `IQueryable`) and `PagedQueryTotalCountTests` pins at least one test per filtering paged handler. Where they are separate repository methods (`MediaCards`), nothing structural is available and `MediaCardsCountMatchesPageTests` pins their agreement instead. Every case asserts LITERAL expected counts AND the literal identities on the page — counts alone would let a count and a page agree on the WRONG SET and stay green. No repo-wide detector is proposed — see the record body for why the obvious one is not reliable.

The two issues were filed as separate bugs on separate entities, and they are one mechanism. #690 (rerun collections) and #758 (playouts) each describe a handler that counts dbContext.<Set>.CountAsync(ct) and then pages a differently-filtered IQueryable. Auditing only the two named handlers would have fixed two of six sites and left the same defect live on collections, multi-collections, smart collections and program schedules — which is an issue's file list is not the population in its most ordinary form: the reporter found the instance that bit them, not the class.

Why the fix is structural rather than "add the missing Where to the count". The natural repair is to give CountAsync a predicate matching the page's. That restores today's correctness and preserves the defect: two expressions stating one intent, which the next person to add a filter has to remember to update in both places. GetPagedMultiCollections and GetPagedSmartCollections were already in exactly that half-state — their counts carried the OwnedByChannelId == null clause, faithfully, and silently omitted the Query clause added later. The predicate that drifts is the one added after the count was written, so no amount of care in the existing line reaches it. Deriving both from one object removes the possibility rather than asserting its absence.

The three MediaCards sites are the reason the population is stated by shape, and the reason the rule needs a second enforcement mode. Their count and their page are two different repository methods, not two expressions in one handler, so "derive both from one IQueryable" has nothing to attach to: GetSeasonCount counted ShowId == showId while GetPagedSeasons pages every show sharing a Title+Year, and the episode and music-video pairs counted the item table while paging the metadata table, so a media item whose metadata row was lost to a scanner failure inflated the total. Where the structure cannot carry the invariant, a test does: MediaCardsCountMatchesPageTests constructs each divergence and asserts count == pageable rows.

Two things that surfaced only by writing those tests, and are the reason they are worth keeping. The seasons count has a THIRD answer nobody would guess from the count alone — with no ShowMetadata row there is nothing to expand from, so GetPagedSeasons returns nothing and the count must be 0 rather than the show's season total; that case is pinned separately. And an include chain can filter more narrowly than the count, in ALL THREE pairs rather than the one it was first noticed in: a REQUIRED reference Include is emitted as an INNER JOIN, so GetPagedEpisodes (Episode -> Season -> Show), GetPagedSeasons (Include(s => s.Show)) and GetPagedMusicVideos (ThenInclude(mv => mv.Artist)) each return nothing when the principal row is absent, while the corrected count still counts. A COLLECTION Include such as Show.ShowMetadata is a LEFT JOIN and drops nothing — the distinction is the whole mechanism, so do not read "an include filters" as a blanket claim. Scope the consequence honestly, the same way the Channel != null clause above is scoped: Episode.SeasonId, Season.ShowId and MusicVideo.ArtistId are all non-nullable with DeleteBehavior.Cascade and production enforces the FK, so count-N / page-0 is a corruption-only state no user can reach. It is stated because it makes "count the table the page reads" necessary and NOT sufficient as a general rule, not because a live defect is being left open; #832 carries it.

GetBlockPlayoutHistory, GetFuturePlayoutItemsById and GetLibraryBrowseItems already did this — they build the filtered query, count it, then page it. The idiom was in the repo; the six defective handlers predate it or were written beside it. That is the reason this is written down as a convention: the correct shape existing somewhere did not stop six handlers from taking the other one.

Why no repo-wide detector. The plausible check is "a handler containing both CountAsync and a conditional Where must count a variable, not a DbSet". It cannot distinguish the legitimate cases: GetPlayoutWarningsCount is a bare count with a predicate that pages nothing, DeleteFFmpegProfileHandler counts rows to decide whether a delete is allowed, and GetLibraryBrowseItems sums five independently-filtered counts across entity types in a way no single-query rule describes. It would also miss the three MediaCards sites entirely, since there the count and the page are not in the same file at all. A detector that flags those reads as noise and gets suppressed. The population is instead enumerated by SHAPE — every handler returning a page plus a count, found by reading the git index for CountAsync/.Skip(/TotalCount rather than for the GetPaged* name — and pinned by at least one test per filtering instance. That distinction is not pedantic: the name-derived population is eight handlers and misses the three MediaCards sites entirely — they were found, and fixed, only because the population was re-derived by shape. That is the failure this record's own first paragraph names, committed once inside the fix for it. (#832 carries what is deliberately left there: 1-based paging and delete-or-keep.)

The tests assert pinned literals, not filter-derived expectations. Each seeds five matchable rows of which exactly two contain "Alpha" — plus, where the handler carries a non-search clause, one row that clause must exclude from BOTH sides (a channel-owned collection, an orphaned playout) — then asserts TotalCount.ShouldBe(2) and the page's names against the literal pair. Recomputing the expectation by re-applying the handler's own predicate would pass whatever the handler does. The mutation proof is recorded in the PR, in both modes: restoring the pre-fix CountAsync clause at all six handler sites turns the six per-handler PagedQueryTotalCountTests cases red, and each of those constructs exactly one handler, so every test is shown to detect its own site rather than a neighbour's; restoring all three pre-fix repository counts turns all four MediaCardsCountMatchesPageTests red.