Server-side data Totals and honesty
Totals and honesty
resultMeta.total is a claim about how many records matched, in one of three strengths — and the strength, not the number, decides what the grid announces and what an export is allowed to call all rows.
A grid that holds a server's answer knows nothing about the records it was not sent. Everything it says about the size of the result — the count a screen reader hears, the label on a CSV file — comes from one prop, resultMeta.total, and it cannot check that prop against the data, because the data it would need is exactly the part it does not have.
So the type does not ask you for a number. It asks for a claim, and for how strong the claim is. PretableMatchingTotal has three shapes — exact, estimate, unknown — and the strength is the load-bearing half: one of the three unlocks things, and the other two exist so that a server which does not know the count has something honest to send.
The grid below sends the same request three ways. The rows never change: the endpoint returns all 480 orders whichever button you pick, because nothing asks it for a page. Only the claim about them changes, and with it what the grid is prepared to say. Its funnels and header sorts are switched off, unlike every other grid in this section — this one's query never leaves the client, and under the filter: "external" it declares, a funnel would set a filter that nothing applies.
The radio buttons change nothing about the records — the same 480 orders come back every time — only how sure the server says it is of the count. Watch what that alone changes: what an export may call all rows, and what the grid is willing to announce as aria-rowcount.
The three shapes
kind | Also carries | Means |
|---|---|---|
exact | count | The server counted the matching records and stands behind the number. The only claim the grid will act on. |
estimate | count | Roughly this many. A planner's row estimate, a sampled count, a capped scan — a number to print, not one to trust. |
unknown | atLeast | The count is not available. atLeast is optional, and belongs there only when the response itself proves it. |
atLeast is the shape's one piece of care. A response that delivered 200 rows starting at offset 0 has proved that at least 200 records match, and nothing beyond that; the endpoint in these examples reports exactly offset + deliveredCount for that reason. What it must not be is a floor you wish were true. If you have no evidence, leave it off — { kind: "unknown" } is a complete value.
What the three do to the announced count is the visible difference, and you can watch it in the readout above. aria-rowcount is the row count of the whole table, rows not in the DOM included, and ARIA defines -1 as its "unknown". An exact total under full external authority — both processing slices external, no grouping — is published as count + 1, the header row being the + 1, so the example announces 481. An estimate cannot be spoken through an attribute whose contract is an integer, so it announces -1, and so does unknown; the number, if you have one, belongs in prose you write. Those conditions are the subject of Query ownership, and short of them the grid publishes the one count it can prove — the rows in its own model.
What none of the three do is change the scroll extent. A grid scrolls over the rows it holds, and a total is a statement about a population, not a promise of scrollable space: claiming 10,000 matches while handing over 200 rows scrolls 200 rows, exactly as it did before you sent a total at all.
What a total is allowed to claim
The rule is one sentence: a server that does not know the count says unknown, rather than guessing an exact.
That sounds obvious and is what most often goes wrong, because an exact count is expensive and a plausible number is right there. SELECT COUNT(*) over a large filtered join is a second query and often the slow one; planner estimates are free; a scan capped at 1,000 knows only "at least 1,000". Each of those has an honest shape here — estimate for the planner's figure, unknown with an atLeast for the capped scan — and reaching for exact because it is the field with the useful behavior is how a grid ends up announcing a number nobody counted.
The asymmetry that makes this worth stating is that the three kinds do not fail alike.
An estimate that over-claims costs nothing. The endpoint here rounds its estimate deliberately up, past the true count, because that is what real query planners do — and the grid is built so it cannot matter. resolveDataScope gates its "all" answer on kind === "exact", and aria-rowcount publishes -1 for anything else, so an estimate is never the input to a decision. It is a number you may print in your own footer and nothing more. The engine does not round it, correct it, or compare it to the rows.
A false exact is a different thing, because exact unlocks. It is the one kind that makes the grid say something it otherwise refuses to say, so a wrong one propagates instead of sitting there. An exact count larger than the truth is published verbatim, so aria-rowcount tells a screen-reader user there are ten thousand rows in a table that stops at 480, and every row position is announced against that figure. An exact count smaller than the records already loaded fails in the other direction: resolveDataScope reads it as "you hold every matching record already" and answers "all", which is how a window onto a larger result comes to be exported under a heading saying it is the whole thing.
The grid catches exactly one case of that, and only because the case contradicts itself: an exact total claiming fewer records than the grid has loaded cannot describe those rows at all. The count is refused for aria-rowcount — the loaded-model count goes out instead — and a warning says so once, in production builds too. That is a floor, not a verification. A total wrong in any way the loaded rows do not contradict is published as given, because there is nothing there to contradict it with.
Both halves of that comparison are read from the same commit, so committing rows and their total together — which is what the props ask for — never trips it. Filtering the overview grid to one region replaces 480 rows and an exact 480 with 120 and an exact 120, and announces 121 with nothing in the console. If you do see the warning, it means what it says: the total and the rows cannot both be right.
Exporting under external authority
The other half of what a total buys is what the grid will call "all rows", and that is a question with one answer per grid rather than one per feature: every user-facing count label and every CSV file routes through resolveDataScope.
import { resolveDataScope, type DataHonestyInput } from "@pretable/react";
const honesty: DataHonestyInput = {
visibleRowCount: rows.length,
isGrouped: false,
loadedRowCount: rows.length,
matchingTotal: total,
};
const scope = resolveDataScope(honesty, { filter: "external" });It reads processing.filter and the total, and nothing else — sort authority does not enter into it. Under engine filter authority the loaded records are the population, so the answer is "all". Under external filtering it takes evidence: an exact total counting no more records than the grid has loaded. Everything else, including every estimate however close it is, is "loaded".
That is why the example above flips as it does. The same 480 rows are on screen in all three states; only exact produces "all", because only exact proves that 480 is the whole of it. Add a limit to that request and even exact becomes "loaded" — which is the ordinary case for a paged remote grid, and the reason the helper is public at all.
A "loaded" scope is not a failure state. It is what puts the unloaded-rows omission into a serialized file and -PARTIAL into its filename, so the person who pressed the button learns that the file is a window. Hardcoding "all" does not make an export complete; it only stops it saying that it is not. The full picture — serializeCsv, the omissions list, and the exportCsv handle that resolves the scope for you — is on CSV export, which links back here for this half of it, as does Clipboard for the group aggregates it scopes the same way.
Those aggregates have a second thing to know about them under this authority, and it is about the number rather than the scope label. Declaring external filtering stops the engine applying query.filters at all, so there is no post-filter subset left for a group to fold — aggregates and child counts cover every loaded row, whichever way aggregateFilteredRows is set. Correct, when the server is the one that chose the records; different, from what the same grid reports under engine filtering.
See also
- Server-side data — the section overview, its endpoint, and what the grid keeps owning.
- Query ownership — the
processingclaim, the filtering it suppresses, and the three things it changes what the grid says about. - CSV export —
serializeCsv, the scope argument, and what a partial file says about itself.