Skip to content

feat(results): page query results from the stats strip (#816) - #1014

Merged
cevheri merged 4 commits into
mainfrom
feat/816-result-pagination
Sep 20, 2026
Merged

cevheri merged 4 commits into
mainfrom
feat/816-result-pagination

Conversation

@cevheri

@cevheri cevheri commented Sep 20, 2026

Copy link
Copy Markdown
Member

Closes #816. This supersedes #933 and #942, and both of their authors are co-authors on the commit.

Two people built this independently while I was slow to settle who had it, which is my fault and not theirs. Rather than pick a winner I took the design in #816 plus the seven review items on #942 and built it once, keeping what each of them got right. @DevvoLazza and @Dharshini-RS03 are credited in the commit and the squash will carry both trailers.

What was wrong

The starter query the object tree generated carried its own row bound, so applyQueryLimit returned it untouched and dropped the offset silently. prepareQuery still reported limit: 500 while the real bound in the statement was 50, so hasMore computed 50 === 500 and the control never rendered. Two defects hid each other: the control was missing, and because it was missing nobody ever triggered the dropped offset behind it.

What changed

The preview cap leaves the SQL text and travels as the limit execution option, so a user written LIMIT n means exactly one thing, a hard bound we do not page past. hasMore additionally requires prepared.wasLimited, because we can only offer page two of a bound this layer applied.

supportsResultPagination is declared by every provider in the repo, each value read from that provider's own prepareQuery rather than from a list. Twelve apply the offset. Cassandra and Elasticsearch refuse it, MongoDB and Redis pin it to zero, and LibreDB never sets wasLimited. The search provider declares this.product.acceptsOffsetClause, because one class serves two type-ids that differ exactly there: a literal true would offer a control that makes Elasticsearch throw, and a literal false would hide one OpenSearch can serve.

No chrome below the grid. LoadMoreFooter and its render site are gone. The former (more available) text in the stats strip is the control, reading 50 rows - load 50 more in the same text-xs font-mono language as its neighbours. The grid keeps its height whether or not another page exists, which the old footer did not.

An unordered query still paginates, and the grid says once, beside the auto-limited badge, that order across pages is not guaranteed. We do not inject an ORDER BY to avoid that.

Beyond the two documents

Three things neither the design nor the review named, found while mapping the code:

  • src/app/api/db/transaction/route.ts computed the same hasMore and needed the same conjunct.
  • src/lib/export/scope.ts reads hasMore to decide export truncation, so the export dialog was telling Cassandra and Elasticsearch users to load rows no control can load. The grid's three-way decision now lives in one place, pageOfferFor, and both the grid and the export scope read it.
  • Clicking the control on a table whose row count is an exact multiple of the page size blanked the grid: the empty page returns fields: [] and the columns went with it. Found by driving the real UI, not by reading.

Verification

All gates run separately, each with its own exit code: format, lint, typecheck, knip, chart:check, channels:showcase:check, readme:check, security:check, test, build, build:lib, attw, test:coverage and coverage:check. 566 test files, 18694 passing, coverage 59259/59259 lines.

Not measured here: the twelve helm-chart-* test files, which need a built chart dependency, and the Go launcher checks. Every provider value was measured through prepareQuery against unconnected configurations rather than live engines, apart from PostgreSQL and SQLite, which were driven in a browser.

The starter query the object tree generated carried its own row bound, so
applyQueryLimit returned it untouched and dropped the offset silently. Two
defects hid each other: the control was missing, and because it was missing
nobody ever triggered the dropped offset behind it.

The preview cap now leaves the SQL text and travels as the limit execution
option, so a user written LIMIT n means exactly one thing, a hard bound we do
not page past. hasMore additionally requires prepared.wasLimited, because we can
only offer page two of a bound this layer applied. supportsResultPagination is
declared by every provider from its own prepareQuery; the search provider
declares this.product.acceptsOffsetClause, because one class serves two type-ids
that differ exactly there.

No chrome below the grid: LoadMoreFooter is gone and the former "(more
available)" text in the stats strip is the control.

Closes #816

Co-authored-by: Lazzaro Davide <211658270+DevvoLazza@users.noreply.github.com>
Co-authored-by: Dharshini_RS <231443604+Dharshini-RS03@users.noreply.github.com>
@codecov

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cevheri

cevheri commented Sep 20, 2026

Copy link
Copy Markdown
Member Author

Hi @Dharshini-RS03 and @DevvoLazza,
Do you have any suggestions or comments?

cevheri and others added 3 commits September 20, 2026 05:40
The embedded adapter had no supersession check. A Load More in flight would
resolve after a Run had replaced the grid, append its stale page on top of the
new rows and rewrite resultQuery, so the tab held rows from two statements while
naming one. That is #881's class, in the copy the standalone hook already
guards. The control never rendered before, so the window was unreachable until
this branch.

The adapter cannot abort, because the host owns the fetch behind onQueryExecute,
so ownership is the mechanism: a per-tab map of the last run to claim each tab,
checked on the success arm and the failure arm of all four paths. The failure
arm matters as much, since a superseded page must not clear the newer run's
flags or raise a toast nobody is waiting for.

Every claim write now takes over both flags in one write. Leaving one behind
stranded the other: a Run that disowned a page could not clear the isLoadingMore
that page had set, and the control reads disabled={isLoadingMore}. Cancel skipped
a paging tab for the same reason, so its predicate widened to match.

Closes #816

Co-authored-by: Lazzaro Davide <211658270+DevvoLazza@users.noreply.github.com>
Co-authored-by: Dharshini_RS <231443604+Dharshini-RS03@users.noreply.github.com>
)

Merging main into this branch resolved the `object-route.ts` conflict by
deleting the line number from eight of the nine citations in the edit-affordance
docblock and leaving the ninth stale, plus a stray blank line inside the block.
`tests/unit/lib/api/object-route-edit.test.ts` is the guard for exactly that and
went red on the merge commit, before this branch's own second commit existed.

Numbers recomputed from each anchor at this tree, not carried over: #985 moved
the provider sites this docblock points at.

Co-authored-by: Lazzaro Davide <211658270+DevvoLazza@users.noreply.github.com>
Co-authored-by: Dharshini_RS <231443604+Dharshini-RS03@users.noreply.github.com>
@sonarqubecloud

Copy link
Copy Markdown

@cevheri
cevheri merged commit 9d26cc0 into main Sep 20, 2026
34 checks passed
@cevheri
cevheri deleted the feat/816-result-pagination branch October 9, 2026 00:45
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.

[FEATURE] Add pagination and infinite scroll to query results

1 participant