074c5868fb
encode_cursor/decode_cursor (base64 <iso8601>|<id>) were defined identically in gallery_service AND post_feed_service, with artist_service importing gallery's copy. Two implementations of one cursor format silently break pagination in whichever feed drifts. Extract to services/pagination.py; gallery/post_feed/ artist all import it. Dropped now-unused base64/datetime imports. §8b: encode_cursor/decode_cursor now defined only in pagination.py. Existing cursor round-trip tests still cover it via the re-export. Catalog updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
32 lines
1.2 KiB
Python
32 lines
1.2 KiB
Python
"""Shared opaque keyset-pagination cursor.
|
|
|
|
A cursor is base64(``<iso8601_sort_key>|<row_id>``). The sort key is whatever
|
|
DESC-ordered timestamp a feed paginates on (gallery: effective_date; posts:
|
|
COALESCE(post_date, downloaded_at); artists: created_at). `decode_cursor` rejects
|
|
a malformed cursor with ValueError, which the API layer maps to HTTP 400.
|
|
|
|
This was hand-rolled identically in gallery_service and post_feed_service (with
|
|
artist_service importing gallery's copy). Two divergent copies of a cursor format
|
|
silently break pagination in whichever feed drifts, so it lives once here now
|
|
(DRY pattern sweep 2026-06-10).
|
|
"""
|
|
|
|
import base64
|
|
from datetime import datetime
|
|
|
|
CURSOR_SEPARATOR = "|"
|
|
|
|
|
|
def encode_cursor(sort_key: datetime, row_id: int) -> str:
|
|
raw = f"{sort_key.isoformat()}{CURSOR_SEPARATOR}{row_id}"
|
|
return base64.urlsafe_b64encode(raw.encode()).decode()
|
|
|
|
|
|
def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
|
try:
|
|
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
|
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
|
|
return datetime.fromisoformat(ts_part), int(id_part)
|
|
except Exception as exc:
|
|
raise ValueError(f"invalid cursor: {cursor!r}") from exc
|