1- import base64
2- import dataclasses
31import datetime
42import json
53import enum
@@ -39,26 +37,58 @@ class SystemKey(enum.StrEnum):
3937}
4038
4139# ---------------------------------------------------------------------------
42- # PageToken
40+ # Cursor encode / decode
4341# ---------------------------------------------------------------------------
4442
43+ CURSOR_SEPARATOR : Final [str ] = "~"
4544
46- @dataclasses .dataclass (kw_only = True )
47- class PageToken :
48- offset : int = 0
49- filter : str | None = None
50- filter_query : str | None = None
5145
52- def encode (self ) -> str :
53- return base64 .b64encode (
54- json .dumps (dataclasses .asdict (self )).encode ("utf-8" )
55- ).decode ("utf-8" )
46+ def encode_cursor (created_at : datetime .datetime , run_id : str ) -> str :
47+ """Encode the last row's position as a tilde-separated cursor string.
5648
57- @classmethod
58- def decode (cls , token : str | None ) -> "PageToken" :
59- if not token :
60- return cls ()
61- return cls (** json .loads (base64 .b64decode (token )))
49+ The created_at from PipelineRun is naive UTC (no UtcDateTime decorator on
50+ this column). We stamp it as UTC here so the cursor string is
51+ timezone-explicit for readability and correctness.
52+ decode_cursor() normalizes back to naive UTC for DB comparison.
53+ """
54+ if created_at .tzinfo is None :
55+ created_at = created_at .replace (tzinfo = datetime .timezone .utc )
56+ return f"{ created_at .isoformat ()} { CURSOR_SEPARATOR } { run_id } "
57+
58+
59+ def decode_cursor (cursor : str | None ) -> tuple [datetime .datetime , str ] | None :
60+ """Parse a tilde-separated cursor string into (created_at, run_id).
61+
62+ Returns None for empty/missing cursors. Raises InvalidPageTokenError
63+ for unrecognized formats (e.g. legacy base64 tokens).
64+ """
65+ if not cursor :
66+ return None
67+ if CURSOR_SEPARATOR not in cursor :
68+ raise errors .InvalidPageTokenError (
69+ f"Unrecognized page_token format. "
70+ f"Expected 'created_at~id' cursor. token={ cursor [:20 ]} ... (truncated)"
71+ )
72+ # maxsplit=1: split on first ~ only, so run_id can safely contain ~
73+ created_at_str , run_id = cursor .split (CURSOR_SEPARATOR , 1 )
74+ created_at = datetime .datetime .fromisoformat (created_at_str )
75+ # Normalize to naive UTC to match DB storage format (PipelineRun.created_at
76+ # is plain DateTime, not UtcDateTime -- stores/returns naive datetimes).
77+ if created_at .tzinfo is not None :
78+ created_at = created_at .astimezone (datetime .timezone .utc ).replace (tzinfo = None )
79+ return created_at , run_id
80+
81+
82+ def maybe_next_page_token (
83+ * ,
84+ rows : list [bts .PipelineRun ],
85+ page_size : int ,
86+ ) -> str | None :
87+ """Return a cursor token for the next page, or None if this is the last page."""
88+ if len (rows ) < page_size :
89+ return None
90+ last = rows [page_size - 1 ]
91+ return encode_cursor (last .created_at , last .id )
6292
6393
6494# ---------------------------------------------------------------------------
@@ -159,26 +189,15 @@ def build_list_filters(
159189 * ,
160190 filter_value : str | None ,
161191 filter_query_value : str | None ,
162- page_token_value : str | None ,
192+ cursor_value : str | None ,
163193 current_user : str | None ,
164- page_size : int ,
165- ) -> tuple [list [sql .ColumnElement ], int , PageToken ]:
166- """Resolve pagination token, legacy filter, and filter_query into WHERE clauses.
167-
168- Returns (where_clauses, offset, next_page_token).
169- """
194+ ) -> list [sql .ColumnElement ]:
195+ """Build WHERE clauses from filters and cursor."""
170196 if filter_value and filter_query_value :
171197 raise errors .MutuallyExclusiveFilterError (
172198 "Cannot use both 'filter' and 'filter_query'. Use one or the other."
173199 )
174200
175- page_token = PageToken .decode (page_token_value )
176- offset = page_token .offset
177- filter_value = page_token .filter if page_token_value else filter_value
178- filter_query_value = (
179- page_token .filter_query if page_token_value else filter_query_value
180- )
181-
182201 if filter_value :
183202 filter_query_value = _convert_legacy_filter_to_filter_query (
184203 filter_value = filter_value ,
@@ -194,13 +213,18 @@ def build_list_filters(
194213 )
195214 )
196215
197- next_page_token = PageToken (
198- offset = offset + page_size ,
199- filter = None ,
200- filter_query = filter_query_value ,
201- )
216+ cursor = decode_cursor (cursor_value )
217+ if cursor :
218+ cursor_created_at , cursor_id = cursor
219+ where_clauses .append (
220+ sql .tuple_ (bts .PipelineRun .created_at , bts .PipelineRun .id )
221+ < sql .tuple_ (
222+ sql .literal (cursor_created_at ),
223+ sql .literal (cursor_id ),
224+ )
225+ )
202226
203- return where_clauses , offset , next_page_token
227+ return where_clauses
204228
205229
206230def filter_query_to_where_clause (
0 commit comments