Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
eaa6cf3
feat(rest): total-count pagination via `Prefer: count=` (Content-Range)
cb1kenobi Aug 10, 2026
1520757
fix(rest): address code-review findings on count pagination
cb1kenobi Aug 11, 2026
8d21379
feat(rest): per-mount `exactCount` config gate for count=exact
cb1kenobi Aug 11, 2026
8e502cf
test(rest): guard count-page Bytes against read-buffer aliasing
cb1kenobi Aug 11, 2026
b3d2e58
fix(rest): echo requested count mode on unavailable totals; robust ex…
cb1kenobi Aug 11, 2026
0a12465
Formatting
cb1kenobi Aug 11, 2026
cac3ae2
fix(rest): require a limit for count so the guardrail can't be bypassed
cb1kenobi Aug 11, 2026
289bfdf
chore: prettier format queryCount test
cb1kenobi Aug 11, 2026
128fcb2
Merge branch 'main' into feat/rest-pagination-total-count
cb1kenobi Aug 13, 2026
5ec0d38
fix(rest): address review — bound count pages, opt-in exact, GET/HEAD…
cb1kenobi Aug 13, 2026
57caa5e
fix(rest): validate count offset and total window, not just the limit
cb1kenobi Aug 13, 2026
beb062d
Merge branch 'main' into feat/rest-pagination-total-count
cb1kenobi Aug 14, 2026
0a2b9ce
Merge branch 'main' into feat/rest-pagination-total-count
cb1kenobi Aug 20, 2026
3d72e2a
Merge branch 'main' into feat/rest-pagination-total-count
cb1kenobi Aug 27, 2026
703e472
Merge branch 'main' into feat/rest-pagination-total-count
cb1kenobi Aug 28, 2026
6bcd6f8
feat(rest): use rocksdb-js estimateCount for range count estimates
cb1kenobi Aug 28, 2026
943e6ee
Merge branch 'main' into feat/rest-pagination-total-count
cb1kenobi Aug 31, 2026
9e44500
fix(rest): don't advertise an HNSW/vector-sorted count as exact
cb1kenobi Aug 31, 2026
088e8c1
fix(rest): treat any custom-index (vector) traversal as an approximat…
cb1kenobi Aug 31, 2026
9517c08
Merge branch 'main' into feat/rest-pagination-total-count
cb1kenobi Aug 31, 2026
d7d4356
perf(rest): keep the exact-count drain from blocking the event loop; …
cb1kenobi Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 193 additions & 1 deletion integrationTests/apiTests/rest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ type SubObject @table(audit: false) @export {
}
`;

const CONFIG_YAML = `rest: true
const CONFIG_YAML = `rest:
exactCount: true
graphqlSchema:
files: '*.graphql'
graphql: true
Expand All @@ -68,6 +69,26 @@ const SUBOBJECT_ROWS = [
{ id: '5', relatedId: '5', any: 'any-5' },
];

// Second component whose REST mount uses the default (exact counting NOT opted in).
const SCHEMA_GATE_GRAPHQL = `
type GatedWidget @table @export(rest: true, mqtt: false) {
id: ID @primaryKey
name: String @indexed
}
`;

const CONFIG_GATE_YAML = `rest: true
graphqlSchema:
files: '*.graphql'
graphql: true
`;

const GATE_ROWS = [
{ id: '1', name: 'w-1' },
{ id: '2', name: 'w-2' },
{ id: '3', name: 'w-3' },
];

const skipSuite = process.platform === 'win32';

suite('REST query syntax', { skip: skipSuite }, (ctx) => {
Expand Down Expand Up @@ -244,4 +265,175 @@ suite('REST query syntax', { skip: skipSuite }, (ctx) => {
)
.expect(200);
});

// `Prefer: count=` (pagination total-count) — emits Content-Range/Range-Unit/Preference-Applied.
test('[rest] count=exact emits an exact Content-Range', () => {
return client
.reqRest('/Related/?sort(id)&limit(2)')
.set('Prefer', 'count=exact')
.expect('Range-Unit', 'items')
.expect('Content-Range', 'items 0-1/5')
.expect('Preference-Applied', 'count=exact')
.expect((r) => assert.equal(r.body.length, 2, r.text))
.expect((r) =>
assert.ok(
(r.headers['access-control-expose-headers'] || '').includes('Content-Range'),
`expected Content-Range to be exposed for CORS, got: ${r.headers['access-control-expose-headers']}`
)
)
.expect(200);
});

test('[rest] count=exact reflects the offset window but a total independent of it', () => {
return client
.reqRest('/Related/?sort(id)&limit(1,3)') // offset 1, 2 rows
.set('Prefer', 'count=exact')
.expect('Content-Range', 'items 1-2/5')
.expect((r) => assert.equal(r.body.length, 2, r.text))
.expect(200);
});

test('[rest] count=exact on a filtered query counts only matches', () => {
return client
.reqRest('/Related/?name==name-2&limit(10)')
.set('Prefer', 'count=exact')
.expect('Content-Range', 'items 0-0/1')
.expect('Preference-Applied', 'count=exact')
.expect(200);
});

test('[rest] count=estimated emits a numeric total flagged estimated', () => {
return client
.reqRest('/Related/?sort(id)&limit(2)')
.set('Prefer', 'count=estimated')
.expect('Preference-Applied', 'count=estimated')
.expect((r) => assert.match(r.headers['content-range'], /^items 0-1\/\d+$/, r.text))
.expect((r) => assert.equal(r.body.length, 2, r.text))
.expect(200);
});

test('[rest] an uncomputable total reports items .../* but still echoes the requested mode', () => {
// `name != x` estimates to Infinity, so the total is unavailable. The header must still say
// count=estimated (the mode applied), not count=none — the client asked, it just can't be given.
return client
.reqRest('/Related/?name!=name-2&limit(2)')
.set('Prefer', 'count=estimated')
.expect('Preference-Applied', 'count=estimated')
.expect((r) => assert.match(r.headers['content-range'], /^items 0-\d+\/\*$/, r.text))
.expect(200);
});

test('[rest] no Prefer header means no Content-Range (opt-in only)', () => {
return client
.reqRest('/Related/?sort(id)&limit(2)')
.expect((r) => assert.equal(r.headers['content-range'], undefined, r.text))
.expect((r) => assert.equal(r.body.length, 2, r.text))
.expect(200);
});

test('[rest] HEAD with count=exact returns the count header and no body', () => {
return request(client.restURL)
.head('/Related/?sort(id)&limit(2)')
.set(client.headers)
.set('Prefer', 'count=exact')
.expect('Content-Range', 'items 0-1/5')
.expect((r) => assert.ok(!r.body || Object.keys(r.body).length === 0, r.text))
.expect(200);
});

test('[rest] an oversized page limit falls through to streaming with no count', () => {
// A limit past the max count-page size must not materialize a count page — the request is served
// normally (all rows) with no Content-Range, rather than buffering an unbounded page.
return client
.reqRest('/Related/?sort(id)&limit(0,20000)')
.set('Prefer', 'count=exact')
.expect((r) => assert.equal(r.headers['content-range'], undefined, r.text))
.expect((r) => assert.equal(r.body.length, 5, r.text))
.expect(200);
});

test('[rest] a deep-page offset past the scan budget falls through with no count', () => {
// limit(start,end) with a huge start is a huge offset; the count path must not iterate an unbounded
// offset before its guardrail engages, so the request falls through with no Content-Range.
return client
.reqRest('/Related/?sort(id)&limit(2000000,2000010)')
.set('Prefer', 'count=exact')
.expect((r) => assert.equal(r.headers['content-range'], undefined, r.text))
.expect(200);
});

test('[rest] a collection read declares Vary: Prefer', () => {
// So a shared cache keys on Prefer and never serves count headers to a request that did not ask.
return client
.reqRest('/Related/?sort(id)&limit(2)')
.expect((r) => assert.match(r.headers['vary'] || '', /\bPrefer\b/i, r.text))
.expect(200);
});

test('[rest] DELETE with a limit and Prefer: count is not misrouted to the count path', () => {
// Regression: the count preference is GET/HEAD-only. A DELETE that also carried limit()+Prefer used
// to receive a materialized array from search() and throw instead of deleting.
return client
.req()
.send({ operation: 'insert', table: 'Related', records: [{ id: 'del-me', name: 'to-delete' }] })
.expect(200)
.then(() =>
request(client.restURL)
.delete('/Related/?id==del-me&limit(10)')
.set(client.headers)
.set('Prefer', 'count=exact')
.expect((r) => assert.ok(r.status >= 200 && r.status < 300, `expected 2xx, got ${r.status}: ${r.text}`))
)
.then(() => client.reqRest('/Related/?id==del-me').expect((r) => assert.equal(r.body.length, 0, r.text)));
});
});

// exactCount is a per-REST-mount policy, so it needs its own instance: two components exporting at
// the root path share one mount (the handler dedupes), and this mount's default config would otherwise
// bleed onto the main suite's routes (which opt in with exactCount: true).
suite('REST count default (exact not opted in)', { skip: skipSuite }, (ctx) => {
let client;

before(async () => {
await startHarper(ctx, { config: {}, env: {} });
client = createApiClient(ctx.harper);

await installAppComponent(client, {
project: 'appCountGate',
files: { 'schema.graphql': SCHEMA_GATE_GRAPHQL, 'config.yaml': CONFIG_GATE_YAML },
probePath: '/GatedWidget/',
restartTimeoutMs: 120000,
});

await client
.req()
.send({ operation: 'insert', table: 'GatedWidget', records: GATE_ROWS })
.expect((r) => assert.ok(r.body.message.includes('inserted 3 of 3 records'), r.text))
.expect(200);
});

after(async () => {
await teardownHarper(ctx);
});

// Default (no exactCount opt-in): count=exact is served as a cheap estimate instead.
test('[rest] default downgrades count=exact to estimated', () => {
return client
.reqRest('/GatedWidget/?sort(id)&limit(2)')
.set('Prefer', 'count=exact')
.expect('Preference-Applied', 'count=estimated')
.expect((r) => assert.match(r.headers['content-range'], /^items 0-1\/\d+$/, r.text))
.expect((r) => assert.equal(r.body.length, 2, r.text))
.expect(200);
});

// estimated still works normally on a default mount.
test('[rest] default leaves count=estimated unchanged', () => {
return client
.reqRest('/GatedWidget/?sort(id)&limit(2)')
.set('Prefer', 'count=estimated')
.expect('Preference-Applied', 'count=estimated')
.expect((r) => assert.equal(r.body.length, 2, r.text))
.expect(200);
});
});
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions resources/RequestTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export class RequestTarget extends URLSearchParams {
declare select?: Select;
/** Return an explanation of the query order */
declare explain?: boolean;
/** Request a total count of matching records for pagination (REST `Prefer: count=exact|estimated`). */
declare count?: 'exact' | 'estimated';
/** Force the query to be executed in the order of conditions */
declare enforceExecutionOrder?: boolean;
declare lazy?: boolean;
Expand Down
Loading
Loading