Summary
queryHeadRequest() (controllers/history.js:86) treats an empty result page as "there are no objects in the database matching the query" (controllers/history.js:99-101) and returns 404. POST /query returns 200 [] for the same request.
POST /query?limit=2 {"type":"NoSuchTypeAtAll"} -> 200, []
HEAD /query?limit=2 {"type":"NoSuchTypeAtAll"} -> 404
POST /query?limit=2&skip=100 {"type":"Thing"} -> 200, [] (29 matches, so this is past the end)
HEAD /query?limit=2&skip=100 {"type":"Thing"} -> 404
The second pair is the one that matters for paging: an exhausted page and a query with no matches are indistinguishable to HEAD, and both differ from what POST reports.
A HEAD response is defined to carry the headers its GET/POST counterpart would send. A 404 against a 200 is not that.
Why this matters
The one endpoint that could cheaply tell a client where a result set ends signals it with a status code the paged endpoint never uses. A client cannot use HEAD to probe ahead of POST without special-casing the disagreement, which defeats the purpose of probing.
HEAD /query is not a count and is easily mistaken for one. It returns the Content-Length of the page it would have sent, subject to the same clamping: limit=2 gives 11832, limit=100 gives 68396, and limit=1000 gives 949640 — the byte size of the clamped 500-record page. It says nothing about how many records match. Past the skip maximum it reports the same Content-Length for the same repeated page on every deployment (1883 local, 1891 devstore, 1404 store), so it inherits the paging bug too.
It is the only paged endpoint left out of the contract work. HEAD /query shares getPagination() with everything else (controllers/history.js:89), so it inherits the validation and header changes automatically. The status-code mismatch is the one thing that does not fall out of the shared fix, which is why it needs its own issue.
Evidence
Verified 2026-09-02 and re-run 2026-09-03, read-only, against localhost:3001, devstore.rerum.io, and store.rerum.io. Behavior is identical on all three.
Affected lines
| File |
Line |
Current |
controllers/history.js |
89, 91 |
HEAD /query paging; no total count |
controllers/history.js |
99-103 |
Returns 404 on an empty page where POST /query returns 200 [] |
openapi/contracts/core-provider.openapi.yaml |
233-239 |
head: /api/query declares 200 and 404, no parameters |
Proposed change
Return 200 with Content-Length: 2 — the byte length of the [] body POST would send — when the page is empty. Drop the 404 branch.
const negotiated = matches.map(o => idNegotiation(o))
const size = Buffer.byteLength(JSON.stringify(negotiated))
res.set("Content-Length", size)
res.status(200).end()
The if (matches.length) guard goes away and the same code path serves both cases, which is also what makes the two verbs agree by construction rather than by matching two branches.
Update the OpenAPI contract to drop 404 from head: /api/query and to declare the pagination parameters, alongside the rest of #305.
Resolve this alongside #96 rather than independently of it. That issue questions whether these HEAD handlers should exist at all. If the answer is that they should be removed, this fix is wasted work; if they stay, this is the change they need. Answer that first, then do one or the other.
Notes
Acceptance criteria
Summary
queryHeadRequest()(controllers/history.js:86) treats an empty result page as "there are no objects in the database matching the query" (controllers/history.js:99-101) and returns 404.POST /queryreturns200 []for the same request.The second pair is the one that matters for paging: an exhausted page and a query with no matches are indistinguishable to
HEAD, and both differ from whatPOSTreports.A HEAD response is defined to carry the headers its GET/POST counterpart would send. A 404 against a 200 is not that.
Why this matters
The one endpoint that could cheaply tell a client where a result set ends signals it with a status code the paged endpoint never uses. A client cannot use
HEADto probe ahead ofPOSTwithout special-casing the disagreement, which defeats the purpose of probing.HEAD /queryis not a count and is easily mistaken for one. It returns theContent-Lengthof the page it would have sent, subject to the same clamping:limit=2gives 11832,limit=100gives 68396, andlimit=1000gives 949640 — the byte size of the clamped 500-record page. It says nothing about how many records match. Past theskipmaximum it reports the sameContent-Lengthfor the same repeated page on every deployment (1883 local, 1891 devstore, 1404 store), so it inherits the paging bug too.It is the only paged endpoint left out of the contract work.
HEAD /querysharesgetPagination()with everything else (controllers/history.js:89), so it inherits the validation and header changes automatically. The status-code mismatch is the one thing that does not fall out of the shared fix, which is why it needs its own issue.Evidence
Verified 2026-09-02 and re-run 2026-09-03, read-only, against
localhost:3001,devstore.rerum.io, andstore.rerum.io. Behavior is identical on all three.Affected lines
controllers/history.jsHEAD /querypaging; no total countcontrollers/history.jsPOST /queryreturns200 []openapi/contracts/core-provider.openapi.yamlhead: /api/querydeclares200and404, no parametersProposed change
Return 200 with
Content-Length: 2— the byte length of the[]bodyPOSTwould send — when the page is empty. Drop the 404 branch.The
if (matches.length)guard goes away and the same code path serves both cases, which is also what makes the two verbs agree by construction rather than by matching two branches.Update the OpenAPI contract to drop
404fromhead: /api/queryand to declare the pagination parameters, alongside the rest of #305.Resolve this alongside #96 rather than independently of it. That issue questions whether these HEAD handlers should exist at all. If the answer is that they should be removed, this fix is wasted work; if they stay, this is the change they need. Answer that first, then do one or the other.
Notes
limitandskipsilently guess at invalid input, and an over-maximumskipreturns the same page forever #301 and Paged responses carry norel="next", so no client can tell a full page from the last page #302 for free throughgetPagination()and should be included in their test coverage either way./queryso paging depth is unbounded and cost is flat #303 lands,HEAD /queryshould acceptcursortoo, so the two verbs stay interchangeable.countDocuments()and a decision about what it costs on an unindexed filter — worth its own issue rather than being smuggled into this one.Acceptance criteria
HEAD /queryandPOST /queryreturn the same status for the same request body and pagination parameters, including on an empty pageContent-Length: 2HEAD /queryinherits the parameter validation andrel="next"behavior of the other paged endpointshead: /api/querymatches the shipped status codes and declares the pagination parameters