@@ -33,6 +33,7 @@ import {
3333 JSONValue ,
3434 Value ,
3535 convexToJson ,
36+ getDocumentSize ,
3637 jsonToConvex ,
3738} from "convex/values" ;
3839import { 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