From 074c5868fb364b0b48cf99b03c258db62ec7e6bf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 10 Jun 2026 00:10:57 -0400 Subject: [PATCH] refactor(services): shared pagination cursor (DRY sweep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encode_cursor/decode_cursor (base64 |) 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) --- backend/app/services/artist_service.py | 3 ++- backend/app/services/gallery_service.py | 18 +------------ backend/app/services/pagination.py | 31 +++++++++++++++++++++++ backend/app/services/post_feed_service.py | 26 +++---------------- 4 files changed, 38 insertions(+), 40 deletions(-) create mode 100644 backend/app/services/pagination.py diff --git a/backend/app/services/artist_service.py b/backend/app/services/artist_service.py index 5be2dcb..20acf06 100644 --- a/backend/app/services/artist_service.py +++ b/backend/app/services/artist_service.py @@ -24,7 +24,8 @@ from ..models import ( from ..models.tag import image_tag from ..utils.slug import slugify from .db_helpers import get_or_create -from .gallery_service import decode_cursor, encode_cursor, thumbnail_url +from .gallery_service import thumbnail_url +from .pagination import decode_cursor, encode_cursor @dataclass(frozen=True) diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 44cbe66..cd5cc2e 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -14,7 +14,6 @@ Decoding rejects malformed cursors with a ValueError; the API layer translates that to HTTP 400. """ -import base64 from dataclasses import dataclass from datetime import datetime @@ -24,8 +23,7 @@ from sqlalchemy.orm import aliased from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag from ..models.tag import image_tag - -CURSOR_SEPARATOR = "|" +from .pagination import decode_cursor, encode_cursor # Reserved `platform` filter value selecting images with NO platformed # provenance (filesystem imports). Returned by facets() as a null-valued @@ -35,20 +33,6 @@ CURSOR_SEPARATOR = "|" UNSOURCED_PLATFORM = "__unsourced__" -def encode_cursor(effective_date: datetime, image_id: int) -> str: - raw = f"{effective_date.isoformat()}{CURSOR_SEPARATOR}{image_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 - - def _effective_date_col(): """The materialized gallery sort key: image_record.effective_date (alembic 0035) = COALESCE(primary post's post_date, created_at), diff --git a/backend/app/services/pagination.py b/backend/app/services/pagination.py new file mode 100644 index 0000000..5f3d01d --- /dev/null +++ b/backend/app/services/pagination.py @@ -0,0 +1,31 @@ +"""Shared opaque keyset-pagination cursor. + +A cursor is base64(``|``). 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 diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index f98debc..a4e1f78 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -1,9 +1,8 @@ """FC-3e: cursor-paginated read service for the Posts stream. -Mirrors GalleryService.scroll's cursor encoding so the frontend pattern -is identical: base64 of "|". Sort key is -COALESCE(Post.post_date, Post.downloaded_at) so posts without a -publish date sort by when we captured them. +Uses the shared `pagination` cursor (base64 of "|") so +every feed paginates identically. Sort key here is COALESCE(Post.post_date, +Post.downloaded_at) so posts without a publish date sort by when we captured them. Pure read-surface; no writes. The service composes the post dict (thumbnails from every image linked to the post — its own primary images @@ -12,9 +11,6 @@ attachments from PostAttachment) so the API layer can jsonify directly. """ from __future__ import annotations -import base64 -from datetime import datetime - from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -28,26 +24,12 @@ from ..models import ( ) from ..utils.text import html_to_plain, truncate_at_word from .gallery_service import thumbnail_url +from .pagination import decode_cursor, encode_cursor -CURSOR_SEPARATOR = "|" DESCRIPTION_LIMIT = 280 THUMBNAIL_LIMIT = 6 -def encode_cursor(sort_key: datetime, post_id: int) -> str: - raw = f"{sort_key.isoformat()}{CURSOR_SEPARATOR}{post_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 - - def _sort_key(): """Postgres COALESCE expression used in ORDER BY and WHERE clauses.""" return func.coalesce(Post.post_date, Post.downloaded_at)