Skip to content

Commit 4c72d60

Browse files
committed
add pagination options support for maximumRowsRead
1 parent 75b35e2 commit 4c72d60

5 files changed

Lines changed: 559 additions & 247 deletions

File tree

convex/pagination.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect, test } from "vitest";
22
import { convexTest } from "../index";
33
import { api } from "./_generated/api";
44
import schema from "./schema";
5+
import type { PaginationResult } from "convex/server";
56

67
test("paginate", async () => {
78
const t = convexTest(schema);
@@ -55,3 +56,139 @@ test("paginate", async () => {
5556
expect(page3).toMatchObject([]);
5657
expect(isDone3).toEqual(true);
5758
});
59+
60+
test("paginate with maximumRowsRead", async () => {
61+
const t = convexTest(schema);
62+
await t.run(async (ctx) => {
63+
for (let i = 0; i < 10; i++) {
64+
await ctx.db.insert("messages", {
65+
author: "sarah",
66+
body: `msg${i}`,
67+
});
68+
}
69+
});
70+
71+
// With maximumRowsRead=3, we should get at most 3 docs and SplitRequired
72+
const result = (await t.query(api.pagination.listAll, {
73+
paginationOptions: {
74+
cursor: null,
75+
numItems: 10,
76+
maximumRowsRead: 3,
77+
},
78+
})) as PaginationResult<any>;
79+
80+
expect(result.page.length).toBeLessThanOrEqual(3);
81+
expect(result.pageStatus).toEqual("SplitRequired");
82+
expect(result.splitCursor).toBeTruthy();
83+
expect(result.isDone).toEqual(false);
84+
85+
// Continue from the continueCursor
86+
const result2 = (await t.query(api.pagination.listAll, {
87+
paginationOptions: {
88+
cursor: result.continueCursor,
89+
numItems: 10,
90+
},
91+
})) as PaginationResult<any>;
92+
93+
// Combined pages should cover all 10 docs
94+
expect(result.page.length + result2.page.length).toEqual(10);
95+
expect(result2.isDone).toEqual(true);
96+
});
97+
98+
test("paginate with maximumBytesRead", async () => {
99+
const t = convexTest(schema);
100+
await t.run(async (ctx) => {
101+
for (let i = 0; i < 5; i++) {
102+
await ctx.db.insert("messages", {
103+
author: "sarah",
104+
body: "x".repeat(100),
105+
});
106+
}
107+
});
108+
109+
// Use a very small byte limit to force early termination
110+
const result = (await t.query(api.pagination.listAll, {
111+
paginationOptions: {
112+
cursor: null,
113+
numItems: 10,
114+
maximumBytesRead: 1, // Very small, should stop after first doc
115+
},
116+
})) as PaginationResult<any>;
117+
118+
expect(result.page.length).toBeLessThanOrEqual(1);
119+
expect(result.pageStatus).toEqual("SplitRequired");
120+
expect(result.isDone).toEqual(false);
121+
});
122+
123+
test("paginate with filter and maximumRowsRead", async () => {
124+
const t = convexTest(schema);
125+
await t.run(async (ctx) => {
126+
// Insert many docs, only some match the filter
127+
for (let i = 0; i < 10; i++) {
128+
await ctx.db.insert("messages", {
129+
author: i % 3 === 0 ? "sarah" : "michal",
130+
body: `msg${i}`,
131+
});
132+
}
133+
});
134+
135+
// Filter for sarah (4 docs: 0, 3, 6, 9), but maximumRowsRead=5
136+
// means we scan at most 5 rows from the pipeline
137+
const result = (await t.query(api.pagination.list, {
138+
author: "sarah",
139+
paginationOptions: {
140+
cursor: null,
141+
numItems: 10,
142+
maximumRowsRead: 5,
143+
},
144+
})) as PaginationResult<any>;
145+
146+
// Should have scanned 5 rows, getting some sarah docs but not all
147+
expect(result.pageStatus).toEqual("SplitRequired");
148+
expect(result.isDone).toEqual(false);
149+
});
150+
151+
test("paginate with endCursor", async () => {
152+
const t = convexTest(schema);
153+
await t.run(async (ctx) => {
154+
for (let i = 0; i < 5; i++) {
155+
await ctx.db.insert("messages", {
156+
author: "sarah",
157+
body: `msg${i}`,
158+
});
159+
}
160+
});
161+
162+
// First get a page to get a cursor
163+
const result1 = (await t.query(api.pagination.listAll, {
164+
paginationOptions: {
165+
cursor: null,
166+
numItems: 2,
167+
},
168+
})) as PaginationResult<any>;
169+
170+
expect(result1.page.length).toEqual(2);
171+
expect(result1.isDone).toEqual(false);
172+
173+
// Now get the next page
174+
const result2 = (await t.query(api.pagination.listAll, {
175+
paginationOptions: {
176+
cursor: result1.continueCursor,
177+
numItems: 2,
178+
},
179+
})) as PaginationResult<any>;
180+
181+
expect(result2.page.length).toEqual(2);
182+
183+
// Use endCursor to re-fetch the second page bounded
184+
const result3 = (await t.query(api.pagination.listAll, {
185+
paginationOptions: {
186+
cursor: result1.continueCursor,
187+
numItems: 100, // Large numItems, but bounded by endCursor
188+
endCursor: result2.continueCursor,
189+
},
190+
})) as PaginationResult<any>;
191+
192+
// Should get the same docs as result2
193+
expect(result3.page.length).toEqual(result2.page.length);
194+
});

convex/pagination.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,12 @@ export const list = query({
1616
.paginate(args.paginationOptions);
1717
},
1818
});
19+
20+
export const listAll = query({
21+
args: {
22+
paginationOptions: paginationOptsValidator,
23+
},
24+
handler: async (ctx, args) => {
25+
return await ctx.db.query("messages").paginate(args.paginationOptions);
26+
},
27+
});

index.ts

Lines changed: 136 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
JSONValue,
3434
Value,
3535
convexToJson,
36+
getDocumentSize,
3637
jsonToConvex,
3738
} from "convex/values";
3839
import { compareValues } from "./compare.js";
@@ -421,41 +422,108 @@ class DatabaseFake {
421422
paginate({
422423
query,
423424
cursor,
425+
endCursor,
424426
pageSize,
427+
maximumRowsRead,
428+
maximumBytesRead,
425429
}: {
426430
query: SerializedQuery;
427431
cursor: string | null;
432+
endCursor?: string | null;
428433
pageSize: number;
434+
maximumRowsRead?: number | null;
435+
maximumBytesRead?: number | null;
429436
}) {
430-
const queryId = this.startQuery(query);
431-
const page = [];
437+
const { sortedDocs, filterFn } = this._resolveQuerySource(query);
438+
439+
const page: GenericDocument[] = [];
432440
let isInPage = cursor === null;
433441
let isDone = false;
434-
let continueCursor = null;
435-
for (;;) {
436-
const { value, done } = this.queryNext(queryId);
437-
if (done) {
438-
isDone = true;
439-
// We have reached the end of the query. Return a cursor that indicates
440-
// "end query", which we can do with any string that isn't a valid _id.
441-
continueCursor = "_end_cursor";
442-
break;
443-
}
444-
if (isInPage) {
445-
page.push(value);
446-
if (page.length >= pageSize) {
447-
continueCursor = value!._id;
442+
let continueCursor: string | null = null;
443+
let splitCursor: string | null = null;
444+
let pageStatus: "SplitRecommended" | "SplitRequired" | null = null;
445+
let rowsRead = 0;
446+
let bytesRead = 0;
447+
const readDocIds: string[] = [];
448+
449+
for (const doc of sortedDocs) {
450+
if (!isInPage) {
451+
if ((doc._id as string) === cursor) {
452+
isInPage = true;
453+
}
454+
continue;
455+
}
456+
457+
// endCursor: stop when we reach this document (inclusive boundary)
458+
if (endCursor && endCursor !== "_end_cursor") {
459+
if ((doc._id as string) === endCursor) {
460+
// Include this doc if it passes filter, then stop
461+
rowsRead += 1;
462+
bytesRead += getDocumentSize(doc);
463+
readDocIds.push(doc._id as string);
464+
if (filterFn(doc)) {
465+
page.push(doc);
466+
}
467+
continueCursor = doc._id as string;
448468
break;
449469
}
450470
}
451-
if (value!._id === cursor) {
452-
isInPage = true;
471+
472+
rowsRead += 1;
473+
bytesRead += getDocumentSize(doc);
474+
readDocIds.push(doc._id as string);
475+
476+
// Check bandwidth limits
477+
let hitLimit = false;
478+
if (maximumRowsRead && rowsRead >= maximumRowsRead) {
479+
hitLimit = true;
480+
}
481+
if (maximumBytesRead && bytesRead >= maximumBytesRead) {
482+
hitLimit = true;
483+
}
484+
485+
if (filterFn(doc)) {
486+
page.push(doc);
487+
}
488+
489+
if (hitLimit) {
490+
pageStatus = "SplitRequired";
491+
continueCursor = doc._id as string;
492+
break;
493+
}
494+
495+
if (!endCursor && page.length >= pageSize) {
496+
continueCursor = doc._id as string;
497+
break;
453498
}
454499
}
500+
501+
if (continueCursor === null) {
502+
isDone = true;
503+
continueCursor = "_end_cursor";
504+
}
505+
506+
// Compute splitCursor at midpoint when limits are hit
507+
if (pageStatus === "SplitRequired" && readDocIds.length >= 2) {
508+
const midIdx = Math.floor((readDocIds.length - 1) / 2);
509+
splitCursor = readDocIds[midIdx];
510+
} else if (
511+
pageStatus === null &&
512+
rowsRead > pageSize + 1 &&
513+
readDocIds.length >= 2
514+
) {
515+
// Recommend split when we had to scan significantly more rows than pageSize
516+
pageStatus = "SplitRecommended";
517+
const midIdx = Math.floor((readDocIds.length - 1) / 2);
518+
splitCursor = readDocIds[midIdx];
519+
}
520+
455521
return {
456522
page,
457523
isDone,
458524
continueCursor,
525+
splitCursor,
526+
pageStatus,
459527
};
460528
}
461529

@@ -478,7 +546,18 @@ class DatabaseFake {
478546
}
479547
}
480548

481-
private _evaluateQuery(query: SerializedQuery): Array<GenericDocument> {
549+
/**
550+
* Resolves the query source: loads documents matching the source (table scan,
551+
* index range, or search), sorts them, and returns along with the compiled
552+
* operator filter function. This separates "rows entering the pipeline"
553+
* (sortedDocs) from "rows exiting" (after filterFn), which is needed for
554+
* maximumRowsRead tracking in paginate().
555+
*/
556+
private _resolveQuerySource(query: SerializedQuery): {
557+
sortedDocs: GenericDocument[];
558+
filterFn: (doc: GenericDocument) => boolean;
559+
limit: number | null;
560+
} {
482561
const source = query.source;
483562
let results: GenericDocument[] = [];
484563
let fieldPathsToSortBy: string[];
@@ -543,6 +622,7 @@ class DatabaseFake {
543622
break;
544623
}
545624
}
625+
546626
const filters = query.operators
547627
.filter(
548628
(operator): operator is { filter: FilterJson } => "filter" in operator,
@@ -554,8 +634,6 @@ class DatabaseFake {
554634
(operator): operator is { limit: number } => "limit" in operator,
555635
)[0] ?? null;
556636

557-
results = results.filter((v) => filters.every((f) => evaluateFilter(v, f)));
558-
559637
results.sort((a, b) => {
560638
const orderMultiplier = order === "asc" ? 1 : -1;
561639
let v = 0;
@@ -568,10 +646,22 @@ class DatabaseFake {
568646
return v * orderMultiplier;
569647
});
570648

649+
const filterFn = (doc: GenericDocument) =>
650+
filters.every((f) => evaluateFilter(doc, f));
651+
652+
return {
653+
sortedDocs: results,
654+
filterFn,
655+
limit: limit?.limit ?? null,
656+
};
657+
}
658+
659+
private _evaluateQuery(query: SerializedQuery): Array<GenericDocument> {
660+
const { sortedDocs, filterFn, limit } = this._resolveQuerySource(query);
661+
let results = sortedDocs.filter(filterFn);
571662
if (limit !== null) {
572-
return results.slice(0, limit.limit);
663+
results = results.slice(0, limit);
573664
}
574-
575665
return results;
576666
}
577667

@@ -1128,13 +1218,32 @@ function asyncSyscallImpl() {
11281218
return JSON.stringify(convexToJson({ value, done }));
11291219
}
11301220
case "1.0/queryPage": {
1131-
const { query, cursor, pageSize } = args;
1132-
const { page, isDone, continueCursor } = db.paginate({
1221+
const {
11331222
query,
11341223
cursor,
1224+
endCursor,
11351225
pageSize,
1136-
});
1137-
return JSON.stringify(convexToJson({ page, isDone, continueCursor }));
1226+
maximumRowsRead,
1227+
maximumBytesRead,
1228+
} = args;
1229+
const { page, isDone, continueCursor, splitCursor, pageStatus } =
1230+
db.paginate({
1231+
query,
1232+
cursor,
1233+
endCursor,
1234+
pageSize,
1235+
maximumRowsRead,
1236+
maximumBytesRead,
1237+
});
1238+
return JSON.stringify(
1239+
convexToJson({
1240+
page,
1241+
isDone,
1242+
continueCursor,
1243+
splitCursor,
1244+
pageStatus,
1245+
}),
1246+
);
11381247
}
11391248
case "1.0/insert": {
11401249
const _id = db.insert(args.table, jsonToConvex(args.value));

0 commit comments

Comments
 (0)