Skip to content

feat(rest): total-count pagination via Prefer: count= (Content-Range) - #2147

Open
cb1kenobi wants to merge 10 commits into
mainfrom
feat/rest-pagination-total-count
Open

feat(rest): total-count pagination via Prefer: count= (Content-Range)#2147
cb1kenobi wants to merge 10 commits into
mainfrom
feat/rest-pagination-total-count

Conversation

@cb1kenobi

@cb1kenobi cb1kenobi commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

Tracking issue: #2162

Adds opt-in total-record-count for REST collection queries so a client can paginate ("1-25 of 1,234") without a second round-trip or a custom resource.

A GET/HEAD on a collection with a Prefer: count= header gets the total in RFC 7233-style response headers:

GET /Product/?category=software&limit(0,25)
Prefer: count=exact
->
200 OK
Content-Range: items 0-24/1234
Range-Unit: items
Preference-Applied: count=exact
  • count=exactTable.search drains the full matched set once, windowing the requested page in the same pass (O(matched) filter evals, O(limit) memory).
  • count=estimated — returns just the page plus a cheap planner/table estimate (estimateCondition / estimatedEntryCount), no full scan.
  • No default — without the header nothing is computed and no headers are emitted.
  • Status is always 200 (Content-Range is informational, not 206). HEAD returns the headers with no body — a cheap "how many match?" pre-flight. The three headers are added to Access-Control-Expose-Headers so browsers can read them cross-origin.

Guardrails and operator control

  • An exact count's tail (past the requested page) is bounded by a row cap and a wall-clock budget; on exhaustion the total is reported unavailable (items x-y/*) rather than truncating the page.
  • Per-mount config rest: { exactCount: false } serves count=exact as an estimate instead (default enabled) for sensitive/public mounts.

Note on the base branch

Stacked on fix/sql-engine-top-limit-normalization (#2124) so this PR's diff is pagination-only. I'll retarget it to main once #2124 merges.

Review findings addressed

Cross-model (Codex) and Harper-domain review surfaced and fixed: read-transaction release on the count path (finally), guardrail no longer truncating the page (+ time budget), estimate corruption by the planner's synthetic sort condition, filter-aware estimates (unknown total instead of a misleading / cardinality-disclosing one), a spurious-header guard for single-record responses, valid-range clamping, and CORS append-not-overwrite. A flagged read-buffer-aliasing concern was verified a non-issue (records return owned copies) and is guarded by a regression test.

Testing

  • Resources unit (unitTests/resources/queryCount*.test.js): exact/estimated/window/filtered/default-streaming, plus a Bytes read-buffer-safety guard on both storage engines.
  • REST integration (integrationTests/apiTests/rest.test.mjs): Content-Range/Range-Unit/Preference-Applied, offset window, filtered, estimated, unavailable-total (/*), opt-in, HEAD, and the exactCount gate (its own instance). 24 REST integration + 11 unit passing; tsc clean.

Docs

REST reference docs (Pagination and Total Count, the exactCount option, the Prefer header): HarperFast/documentation#623.

🤖 Generated with Claude Code

cb1kenobi and others added 5 commits August 10, 2026 10:14
Adds opt-in total-record-count for REST collection queries so a client can paginate
("1–25 of 1,234") without a second round-trip or a custom resource.

- `Prefer: count=exact` — Table.search drains the full matched set once, windowing the
  requested page in the same pass (O(matched) filter evals, O(limit) memory), bounded by
  MAX_EXACT_COUNT_SCAN so a page fetch can't turn into an unbounded scan.
- `Prefer: count=estimated` — returns just the page plus a cheap planner/table estimate
  (estimateCondition / estimatedEntryCount, now exported), no full scan.
- No default: without the header nothing is computed and no header is emitted.
- REST emits `Content-Range: items <start>-<end>/<total>` (200, not 206), `Range-Unit:
  items`, and `Preference-Applied: count=exact|estimated|none`, and adds them to
  `Access-Control-Expose-Headers` so browser (CORS) clients can read them. HEAD returns
  the headers with no body — a cheap "how many match?" pre-flight.

Tests: resources-level unit (exact/estimated/window/filtered/default streaming) and REST
integration (Content-Range/Range-Unit/Preference-Applied/CORS/HEAD/opt-in).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-model review (Codex) of the count feature surfaced several correctness,
resource, and disclosure issues, all fixed here:

- Read-txn leak: the count drain now releases the read transaction in a `finally`,
  so a throw mid-iteration (record load, rowFilter policy error) can't leak a pinned
  snapshot.
- Guardrail no longer truncates the page: the requested [offset, end) window is
  always collected in full; the row cap only abandons the running total. Added a
  wall-clock budget (MAX_EXACT_COUNT_MS) alongside the row cap so an exact count of a
  large match set can't run unbounded — on exhaustion the total is reported unknown
  (Content-Range .../*), never a short page.
- Estimated totals no longer corrupted by the planner's synthetic `sort`
  pseudo-condition: hasUserConditions now reads the raw request conditions, and the
  estimate drops `sort` pseudo-conditions. A clamp keeps a non-empty page's
  Content-Range valid when an estimate undershoots (exact totals stay authoritative).
- Estimated totals return unknown (null -> .../*) when an opaque rowFilter/vectorFilter
  participates, instead of a misleading estimate that could disclose hidden cardinality.
- Spurious headers: the REST gate now requires an array result, so a single-record GET
  whose record carries a `recordCount` attribute can't be mistaken for a count page.
- CORS: Access-Control-Expose-Headers is appended (not overwritten), preserving a
  resource's own exposed headers.

Adds regression tests for the sorted-estimate, filter-aware estimate, and
filtered-exact paths. Resources unit 8 passing; REST integration 21 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an operator control for the expensive exact-count scan: `rest: { exactCount:
false }` on a REST mount serves a `Prefer: count=exact` request as a cheap estimate
instead (signaled back via `Preference-Applied: count=estimated`), rather than
rejecting it. Default enabled. Read from httpOptions in the same per-mount way as the
existing `includeExpensiveRecordCountEstimates` option.

This is the operator-facing half of the DoS mitigation for exact counts: the in-code
guardrails (row cap + time budget) bound a single request, and this lets a deployment
turn exact counts off entirely on a sensitive/public mount. It is a per-REST-mount
policy — components exporting at the shared root path share one mount's options.

Integration: a dedicated suite (its own instance, since a gated component would
otherwise share the root mount with the main suite) verifies count=exact downgrades to
estimated while count=estimated is unchanged. 23 REST integration tests passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A code-review concern held that the count path releases its read transaction before
the page is serialized, so a Bytes/Blob field decoded as a zero-copy view of the read
buffer could be corrupted by later reads/writes. Verified it does NOT occur: the count
drain reads every record eagerly while the txn is open and returns owned copies
(Bytes come back as standalone Buffers, byteOffset 0), so releasing before serialize is
safe — unlike the streaming path, which reads lazily during serialization and must hold
the txn. This test churns writes/reads after an exact count and asserts the returned
Bytes are unchanged, on both storage engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…actCount gate

Addresses two review findings:

- #2: Preference-Applied now echoes the count mode the server applied (exact|estimated,
  after any per-mount downgrade) instead of `count=none` when the total is unavailable.
  A `Content-Range: items x-y/*` now reads as "that mode was applied but the total is
  unavailable" (guardrail hit, or an estimate suppressed by an opaque filter / Infinity
  estimate) rather than "no count was requested". Added an integration case: a `ne`
  condition (Infinity estimate) yields items 0-.../* with count=estimated.

- #4: the exactCount disable check also accepts the string "false", since not every
  config source coerces to a boolean.

24 REST integration tests passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for REST pagination total-count using the Prefer: count=exact|estimated header. It updates Table.search to return materialized pages with exact or estimated record counts, incorporating guardrails to prevent unbounded scans. The REST layer is updated to parse the preference, handle configuration-based downgrades, and emit RFC 7233-style headers (Content-Range, Range-Unit, and Preference-Applied). Additionally, new unit and integration tests are added to verify the functionality and ensure read-buffer safety. There are no review comments, and I have no feedback to provide.

Comment thread resources/Table.ts Outdated
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found. This push (5ec0d38) addresses all 5 outstanding review threads: bounded count-page validation (finite/non-negative/<=10k limit), exact counting now opt-in per mount (default off), Prefer: count restricted to GET/HEAD (fixing the DELETE misroute), Vary: Prefer emitted after serialize, and case-insensitive token comparison for Access-Control-Expose-Headers. Each fix has matching test coverage.

Comment thread resources/Table.ts Outdated
cb1kenobi and others added 3 commits August 11, 2026 18:26
Review (claude[bot] on #2147) found the exact-count guardrail (row cap + time
budget) and the estimated early-exit only applied when the request included a
limit(): both live inside `if (end !== undefined ...)`. A count=exact/estimated
request with no limit() therefore drained AND materialized the entire matched set
with no cap — the exact unbounded-scan/-memory DoS the guardrail was built to
prevent, on the most likely-hit path (a bare collection GET), and it bypassed the
exactCount gate too.

Counting is a pagination feature, so it now requires a limit(): a count request
without one falls through to the normal streaming path (no count emitted), which
keeps the guardrail always applied to a bounded page. Updated the unit test that
documented the no-limit drain as intentional, and added a test asserting a no-limit
count streams (does not materialize).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Base automatically changed from fix/sql-engine-top-limit-normalization to main August 12, 2026 22:47

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really cool idea, I like the interface.
However, I agree with the comments; I think we should probably have exactCount disabled by default, it could pretty expensive. I also think this really increases the need for HarperFast/rocksdb-js#311 or some better solution for estimating range counts (maybe should be a prerequisite for this?)
And do we have an issue for this? I'd like to get priorities associated with these (especially if this starts entraining dependency PRs).
🤖 Reviewed with Codex

Comment thread resources/Table.ts Outdated
// likely-hit path (a bare collection GET). A count request without a limit therefore falls
// through to the normal streaming path (no count emitted), so the guardrail below always
// applies to a bounded page rather than being skipped when `end` is undefined.
if (target.count && target.limit !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new check closes the undefined-limit case, but it does not establish that the page is actually bounded. REST parsing assigns +args[0] at resources/search.ts:1370-1377, so limit(Infinity) makes pageEnd infinite and scanned >= pageEnd never becomes true; a huge finite limit similarly materializes the whole match set before any guard applies. limit(foo) produces NaN, causing both page and guard comparisons to remain false while exact counting drains the full set. Please validate a finite, non-negative limit and enforce a maximum count-page size before entering this materializing branch, with tests for non-finite and oversized values.

— KrAIs (GPT-5)

Comment thread server/REST.ts Outdated
// A mount can disable the expensive exact scan with `rest: { exactCount: false }` (default
// enabled); a count=exact request is then served as a cheap estimate. Accept a string
// `"false"` too, since not every config source coerces to a boolean.
const exactDisabled = (httpOptions as any).exactCount === false || (httpOptions as any).exactCount === 'false';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact counting is effectively enabled by default here. A request such as GET ...?limit(1) with Prefer: count=exact returns one row but then drains up to one million matches or one second in resources/Table.ts:3528-3535. Those limits are per request, so concurrent requests can each consume the full budget and hold read snapshots, making a public exported table an inexpensive worker-saturation path. Please make exact counting opt-in (exactCount: true) or enforce a shared concurrency/cancellation budget; estimated counting can remain the safe default.

— KrAIs (GPT-5)

Comment thread resources/Table.ts
const wantExact = target.count === 'exact';
const pageEnd = offset + (target.limit as number);
const countStart = performance.now();
return (async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conditionally changes the declared search() contract from AsyncIterable (resources/Table.ts:3098) to Promise<Array>. That still breaks an internal path: REST attaches target.count for every HTTP method, and collection deletion immediately does for await (const entry of this.search(scanTarget)) at resources/Table.ts:2997-3003; a DELETE carrying both limit(...) and Prefer: count=exact therefore receives a Promise and throws instead of deleting. Please restrict the REST preference to GET/HEAD and either preserve the iterable contract or expose counting through an explicit, accurately typed API/overload with all callers updated.

— KrAIs (GPT-5)

Comment thread server/REST.ts
const range = len > 0 ? `${offset}-${offset + len - 1}` : '*';
const totalStr = typeof total === 'number' ? String(total) : '*';
headers.set('Range-Unit', 'items');
headers.set('Content-Range', `items ${range}/${totalStr}`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These response headers depend on the request's Prefer value, but the URL and body do not, and no final Vary: Prefer is emitted. A shared cache can therefore serve count headers to a request without the preference, or serve a cached non-count response to a client that requested totals. Please add Prefer to the final Vary value after serialization—serialize() later replaces Vary at server/REST.ts:406, so adding it only inside this helper would still be lost.

— KrAIs (GPT-5)

Comment thread server/REST.ts Outdated
// Append (don't overwrite) so a resource that already exposed its own headers keeps them.
const exposed = headers.get('Access-Control-Expose-Headers');
for (const name of ['Content-Range', 'Range-Unit', 'Preference-Applied']) {
if (!exposed || !String(exposed).toLowerCase().includes(name.toLowerCase())) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please compare comma-delimited exposure names as case-insensitive tokens rather than substrings. For example, an existing Access-Control-Expose-Headers: X-Content-Range-Metadata currently suppresses the actual Content-Range token, leaving it unreadable to browser clients. Splitting and trimming the existing value, as the Vary helper does, avoids that collision.

— KrAIs (GPT-5)

… only, Vary/CORS

Addresses kriszyp's review on #2147:

- Bound the count page: the count path now requires a finite, non-negative integer
  limit no larger than MAX_COUNT_PAGE (10k). limit(Infinity), limit(foo)->NaN, a
  negative, or an oversized limit fall through to streaming with no count, so a
  count request can't be coerced into materializing an unbounded page.
- Exact counting is now opt-in per mount (`rest: { exactCount: true }`, default
  off); count=exact is otherwise served as an estimate. Estimated stays the safe
  default, removing the default worker-saturation surface on public tables.
- Only honor Prefer: count on GET/HEAD. It was set for every method, so a
  collection DELETE carrying limit()+Prefer received a materialized array from
  search() (declared AsyncIterable) and threw instead of deleting.
- Emit `Vary: Prefer` on collection reads (after serialize, which resets Vary) so a
  shared cache can't serve count headers to a request that didn't ask, or a cached
  non-count response to one that did.
- Compare Access-Control-Expose-Headers as case-insensitive comma tokens, not
  substrings, so an unrelated existing token (e.g. X-Content-Range-Metadata) no
  longer suppresses the real Content-Range token.

Tests: unit 11 passing (added invalid/oversized-limit fall-through); integration 27
passing (oversized-limit fall-through, Vary: Prefer, DELETE-not-misrouted, and the
new opt-in default via exactCount: true / default-off suites).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cb1kenobi

Copy link
Copy Markdown
Member Author

Thanks @kriszyp — really helpful review, all five points were spot on. Addressed in 5ec0d38:

Design

  • Exact counting is now opt-in (rest: { exactCount: true }, default off); count=exact is otherwise served as an estimate. Estimated stays the safe default, so a public table no longer exposes the exact-scan budget by default.
  • Tracking issue: REST pagination total count (Prefer: count=) #2162 — links rocksdb-js#311 as the range-estimation dependency, and flags there the "should better estimation be a prerequisite for broader rollout?" question for prioritization.

Fixes

  1. Unbounded/invalid limit — the count path now requires a finite, non-negative integer limit no larger than a max page size (10k); limit(Infinity), limit(foo)→NaN, negative, or oversized limits fall through to streaming with no count.
  2. search() contract / DELETEPrefer: count is now GET/HEAD-only, so a collection DELETE carrying limit()+Prefer keeps the AsyncIterable and deletes instead of receiving a Promise and throwing.
  3. Vary: Prefer — emitted on collection reads after serialization (which resets Vary), so shared caches key on Prefer.
  4. CORS token comparison — expose-header dedup now compares case-insensitive comma tokens instead of substrings, so X-Content-Range-Metadata no longer suppresses Content-Range.

Docs updated in HarperFast/documentation#623. Tests: unit 11, integration 27 green. Ready for another look when you have a moment.

— Claude Opus 4.8

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.

3 participants