Repository navigation
/query paged responses carry no rel="next", so no client can tell a full page from the last page #302
Description
Summary
Nothing in a /query response says whether more results exist. A client has to infer it from the number of records it received, and both available inferences are wrong:
- Stopping when
results.length < limitsilently drops records, because a clampedlimitproduces a short page that is not a final page. - Stopping when
results.length === 0and advancing byresults.lengthnever reaches the end of a result set larger than theskipmaximum. Beforelimitandskipsilently guess at invalid input, and an over-maximumskipreturns the same page forever #301 every request past the maximum returned the same page forever. Sincelimitandskipsilently guess at invalid input, and an over-maximumskipreturns the same page forever #301 the walk ends in a400instead, which is honest but still leaves the rest of the result set unread.
The fix is to stop making clients guess. /query emits Link: <…>; rel="next" (RFC 8288) while another page exists and omits it when the result set is exhausted. Clients follow next and stop when it is absent, which makes every length-based stop condition irrelevant in one move.
This issue is scoped to /query. /search paging is tracked under #306 and its sub-issues, and the /gog/*InManuscript endpoints are out of scope (see Notes).
Why this matters
Both client shapes fail, in opposite directions. From the parent draft (#299), before #301 landed, both run against the same query on the same deployment:
# stop on short page, limit=1000
req #1: skip=0 -> 500 docs
=> 500 records (client believes the walk is complete; the query matches more than 100000)
# stop on empty page, limit=100, entered at skip=99800
req #3: skip=100000 -> 100 docs, first=4c0ef019
req #4: skip=100100 -> 100 docs, first=4c0ef019
req #5: skip=100200 -> 100 docs, first=4c0ef019
...never terminates
With rel="next" the first client gets a next link on its 500-record page and keeps going; the second gets no next link at the end and stops. Neither has to know what the server's maximums are.
A client that wants to defend itself still has to do arithmetic. #301 added Pagination-Limit, Pagination-Skip, Pagination-Limit-Max, and Pagination-Skip-Max, so a client can now detect a clamped limit and the depth ceiling. It still has to combine record counts with those headers to decide when to stop, and every client has to get that logic right independently. The only Link emitted today is the JSON-LD context (utils.js:163).
The published example still guesses. pagedQuery at public/API.html:556 stops when results.length < appliedLimit and treats the skip ceiling 400 as the end of the walk. It is correct for the current contract, but it is exactly the counting logic a next link makes unnecessary, and it cannot read past the maximum.
Affected lines
| File | Line | Current |
|---|---|---|
utils.js |
163 | configureLDHeadersFor() assigns Link as a single string — the context link only |
controllers/crud.js |
87 | /query — .limit(limit), needs limit + 1 |
public/API.html |
556 | pagedQuery example stops on a record count rather than following a link |
openapi/contracts/core-provider.openapi.yaml |
210 | /api/query declares no Link response header |
Design
Know whether another page exists
/query over-fetches one record and trims it before serialization. The extra record is never returned; its existence is the signal.
let matches = await db.find(props).sort({ _id: 1 }).limit(limit + 1).skip(skip).toArray()
const hasNext = matches.length > limit
matches = matches.slice(0, limit).map(o => idNegotiation(o))Emit the header
A new helper, setNextPageLink(req, res, { limit, skip, hasNext }) in controllers/utils.js, adds the next link. /query calls it after res.set(utils.configureLDHeadersFor(matches)).
Link: <http://store.rerum.io/v1/context.json>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
Link: <https://store.rerum.io/v1/api/query?limit=3&skip=9>; rel="next"
That is the response to ?limit=3&skip=6. The rules:
- Append, do not replace.
configureLDHeadersFor()is unchanged. The helper callsres.append("Link", …), so thenextlink arrives as a secondLinkfield after the context link, which RFC 9110 treats the same as one comma-joined value. - Absolute URLs on the
process.env.RERUM_PREFIXorigin, never the request host. The path is the route that was paged (req.baseUrl+req.path, trailing slash trimmed), so the legacygetByProperties.actionalias links to/v1/api/query. IfRERUM_PREFIXis unset, the links are path-only references, which RFC 8288 allows. - Only paging parameters are carried:
limit(the applied value, after clamping) andskip. nextis present only while another page exists. Absence is the termination signal, so it is genuinely absent on the final page, including a final page that is exactlylimitrecords long. It advancesskipby the appliedlimit.nextis still sent past theskipmaximum. When the next page would start beyondMAX_QUERY_SKIP, the link is present anyway, and following it is answered with thelimitandskipsilently guess at invalid input, and an over-maximumskipreturns the same page forever #301400that names the maximum. Leaving it out would tell a client the walk was complete when it was not, which is the silent truncation this issue exists to stop. A result set longer than the maximum therefore ends loudly. Unbounded depth is Add cursor-based continuation to/queryso paging depth is unbounded and cost is flat #303, deferred.- Only
next.first,prev, andlastare not sent. A client already has the URL it started from.prevwould need sizing rules for askipsmaller thanlimitthat no client has asked for, and would have no offset to go back from under a future cursor (Add cursor-based continuation to/queryso paging depth is unbounded and cost is flat #303).lastwould need a total count. /queryis a POST endpoint. The link carries no request body; the client re-sends the same body to it.public/API.htmlsays so explicitly.Access-Control-Expose-Headersis already*(app.js:53), so browser clients can read the header without further CORS work.
Documentation
public/API.html: the Custom Query text tells clients to followrel="next"until there is none.pagedQueryis rewritten as a loop that re-sends the same request to eachnextlink, stops when the header has none, and warns that its results are incomplete if anextlink past theskipmaximum is answered with the400. A new "Followingrel="next"" section under Pagination parameters describes the link and its behavior at the maximum, shows an example header, and notes that the same body goes to the link.openapi/contracts/core-provider.openapi.yaml: aNextPageLinkheader component describes the link, and the/api/query200response references it asLink.
Verification
Tests:
routes/__tests__/query.test.js,rel="next" links on /query:nextis present and advancesskipwhile more records exist, and is absent on the final page. The JSON-LD context link survives, a final page carries no paging link at all, and the extra record is never served at the maximumlimit. At theskipmaximumnextis still present, and following it is a400carryingPagination-Skip-Max. A walk that follows onlynextreturns every record once.__tests__/utils.test.js,setNextPageLink: an absolute link on theRERUM_PREFIXorigin that advancesskipby the limit, a path-only link without it, nothing added on the final page, and an append rather than a replace.
Read-only, against the local pm2 app reading the dev collection (rerum-test):
?limit=3&skip=6over{"__rerum.history":{"$exists":true}}returned the context link plusnext, as in the example above.- A query matching one record, and a query matching exactly
limit=2records, each returned the context link only, with nonext. - Following only
rel="next"atlimit=500over{"_id":{"$type":"string","$gte":"69e9"}}read 2433 records in 5 pages and stopped whennextwas absent. The walk was identical, document for document, to an independentskipwalk atlimit=250. - Following only
rel="next"atlimit=500fromskip=99000over{"__rerum.history":{"$exists":true}}, which matches 102001 records, read 1500 records and then got a400naming the maximum of 100000 from theskip=100500link.
Notes
- Additive and non-breaking. Existing clients ignore the header and behave exactly as they do now. This can ship without client coordination.
/querypaginates with no sort, so page boundaries rest on MongoDB natural order #300 is closed, so/querypages over a deterministic_idorder and anextlink promises nothing the server cannot keep.- Add cursor-based continuation to
/queryso paging depth is unbounded and cost is flat #303 is deferred as a feature to consider later. If it lands,nexton/queryshould carry a cursor instead of askip, which lifts theskipmaximum for clients that follow the link, without any change on their side. A conforming client never builds a URL, so that swap is invisible to it. HEAD /queryis not part of this issue.HEAD /queryanswers an empty page with 404 wherePOST /queryanswers200 []#304 removes it, because a POST endpoint has no HEAD counterpart./searchis out of scope. Its paging is tracked under/searchpaging is unfinished: cost scales with depth, norel="next", and pages under-fill #306./searchpaginates in application memory, so every page costs the server everything up to that page #309 now carries therel="next"criteria for/search, and should reusesetNextPageLink(). Until/searchmerge sorts on a field that does not exist and drops documents whose_idis an embedded object #307 lands, the result pool a/searchnextlink would walk is still missing documents./gog/*InManuscriptis out of scope. Those endpoints page over a window of candidate Annotations rather than over the entities they return, which is whylimitandskipsilently guess at invalid input, and an over-maximumskipreturns the same page forever #301 left thePagination-*headers off them. They need their own treatment.- The pagination contract is undocumented, misdocumented, and absent from the OpenAPI contract #305 remains the final documentation sweep.
/historyand/sinceload an object's entire version chain into memory, unpaged #252 is still open and its third recommendation is cursor-based pagination, which this issue is the first half of. Add cursor-based continuation to/queryso paging depth is unbounded and cost is flat #303 is the second.
Acceptance criteria
-
/queryresponses carryLink: …; rel="next"while more results exist, and omit it on the final page - The JSON-LD context link is still present alongside
rel="next" - The
nextlink is absolute on theRERUM_PREFIXorigin and carries only paging parameters -
rel="next"is still present when the next page would start past theskipmaximum, and following it returns the400that names the maximum - The over-fetched record is never returned to the client, at any
limitincluding the maximum - A client following only
rel="next"walks a/queryresult set to completion and terminates, with no length-based stop condition - Regression tests cover
rel="next"presence, its absence on the final page, and the untrimmed extra record never appearing -
public/API.htmlpublishes apagedQueryexample that followsrel="next", and says the same request body goes to the link - The
Linkresponse header is declared for/api/queryinopenapi/contracts/core-provider.openapi.yaml
Activity
/queryand/searchhonest and self-describing #299[-]Paged responses carry no `rel="next"`, so no client can tell a full page from the last page[/-][+]`/query` paged responses carry no `rel="next"`, so no client can tell a full page from the last page[/+]