@@ -29,13 +29,14 @@ class User(BubbleModel, typename="user"):
2929from pydantic import BaseModel as PydanticBaseModel
3030from pydantic import Field
3131
32- from bubble_data_api_client .client .raw_client import AdditionalSortField , RawClient
32+ from bubble_data_api_client .client .raw_client import (
33+ _DEFAULT_PAGE_SIZE ,
34+ AdditionalSortField ,
35+ RawClient ,
36+ )
3337from bubble_data_api_client .constraints import Constraint , ConstraintType , constraint
3438from bubble_data_api_client .exceptions import BubbleAPIError , UnknownFieldError
35- from bubble_data_api_client .types import BUILTIN_FIELDS , BubbleField , OnMultiple
36-
37- # default page size for paginated requests.
38- _DEFAULT_PAGE_SIZE : int = 100
39+ from bubble_data_api_client .types import BUILTIN_FIELDS , BubbleField , OnMultiple , PageResult
3940
4041# max UIDs per "in" constraint batch, matching the API's max page size.
4142_MAX_IN_CONSTRAINT_SIZE : int = 100
@@ -219,6 +220,55 @@ async def find(
219220 )
220221 return [cls .model_validate (item ) for item in response .json ()["response" ]["results" ]]
221222
223+ @classmethod
224+ async def find_page (
225+ cls ,
226+ * ,
227+ constraints : list [Constraint ] | None = None ,
228+ cursor : int = 0 ,
229+ limit : int = _DEFAULT_PAGE_SIZE ,
230+ sort_field : str | None = None ,
231+ descending : bool | None = None ,
232+ additional_sort_fields : list [AdditionalSortField ] | None = None ,
233+ ) -> PageResult [typing .Self ]:
234+ """Return one page of matching records with envelope metadata.
235+
236+ Unlike find(), this preserves Bubble's response envelope so callers
237+ can display total counts and drive pagination UIs without issuing a
238+ separate count() call.
239+
240+ See RawClient.find_page for important caveats about Bubble's
241+ pagination limits: the 100-item silent cap on limit, the ~50,000
242+ cursor cap on shared infrastructure, and the recommended keyset
243+ pagination workaround for collections larger than the cursor cap.
244+
245+ Args:
246+ constraints: Filter conditions (use constraint() helper to build).
247+ cursor: Pagination offset (0-indexed).
248+ limit: Maximum results to return on this page.
249+ sort_field: Field name to sort by.
250+ descending: Sort in descending order if True.
251+ additional_sort_fields: Secondary sort fields after the primary.
252+
253+ Returns:
254+ PageResult with typed model instances plus envelope metadata.
255+ """
256+ async with _get_client () as client :
257+ page = await client .find_page (
258+ cls ._typename ,
259+ constraints = constraints ,
260+ cursor = cursor ,
261+ limit = limit ,
262+ sort_field = sort_field ,
263+ descending = descending ,
264+ additional_sort_fields = additional_sort_fields ,
265+ )
266+ return PageResult (
267+ items = [cls .model_validate (item ) for item in page .items ],
268+ cursor = page .cursor ,
269+ remaining = page .remaining ,
270+ )
271+
222272 @classmethod
223273 async def find_iter (
224274 cls ,
@@ -229,7 +279,15 @@ async def find_iter(
229279 descending : bool | None = None ,
230280 additional_sort_fields : list [AdditionalSortField ] | None = None ,
231281 ) -> AsyncIterator [typing .Self ]:
232- """Iterate through all matching records with constant memory usage."""
282+ """Iterate through all matching records with constant memory usage.
283+
284+ Offset pagination is capped at ~50,000 on Bubble's shared
285+ infrastructure. For collections larger than that cap, prefer
286+ keyset pagination (a Created Date constraint) instead of this
287+ method, which will stop short at the cap. The empty-page guard
288+ below prevents an infinite loop when the cursor reaches the cap
289+ (Bubble continues to report a non-zero remaining past the cap).
290+ """
233291 cursor : int = 0
234292 async with _get_client () as client :
235293 while True :
@@ -245,6 +303,11 @@ async def find_iter(
245303 body = response .json ()["response" ]
246304 for item in body ["results" ]:
247305 yield cls .model_validate (item )
306+ # stop on empty page first: past the cursor cap Bubble can
307+ # return zero results while still reporting remaining > 0,
308+ # which would otherwise cause an infinite loop.
309+ if not body ["results" ]:
310+ break
248311 if body ["remaining" ] == 0 :
249312 break
250313 cursor += len (body ["results" ])
0 commit comments