refactor(services): shared pagination cursor (DRY sweep)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 43s
CI / integration (push) Successful in 3m21s

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>
This commit is contained in:
2026-06-10 00:10:57 -04:00
parent f1a664e5a7
commit 074c5868fb
4 changed files with 38 additions and 40 deletions
+2 -1
View File
@@ -24,7 +24,8 @@ from ..models import (
from ..models.tag import image_tag from ..models.tag import image_tag
from ..utils.slug import slugify from ..utils.slug import slugify
from .db_helpers import get_or_create 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) @dataclass(frozen=True)
+1 -17
View File
@@ -14,7 +14,6 @@ Decoding rejects malformed cursors with a ValueError; the API layer
translates that to HTTP 400. translates that to HTTP 400.
""" """
import base64
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -24,8 +23,7 @@ from sqlalchemy.orm import aliased
from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag from ..models import Artist, ImageProvenance, ImageRecord, Post, Source, Tag
from ..models.tag import image_tag from ..models.tag import image_tag
from .pagination import decode_cursor, encode_cursor
CURSOR_SEPARATOR = "|"
# Reserved `platform` filter value selecting images with NO platformed # Reserved `platform` filter value selecting images with NO platformed
# provenance (filesystem imports). Returned by facets() as a null-valued # provenance (filesystem imports). Returned by facets() as a null-valued
@@ -35,20 +33,6 @@ CURSOR_SEPARATOR = "|"
UNSOURCED_PLATFORM = "__unsourced__" 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(): def _effective_date_col():
"""The materialized gallery sort key: image_record.effective_date """The materialized gallery sort key: image_record.effective_date
(alembic 0035) = COALESCE(primary post's post_date, created_at), (alembic 0035) = COALESCE(primary post's post_date, created_at),
+31
View File
@@ -0,0 +1,31 @@
"""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
+4 -22
View File
@@ -1,9 +1,8 @@
"""FC-3e: cursor-paginated read service for the Posts stream. """FC-3e: cursor-paginated read service for the Posts stream.
Mirrors GalleryService.scroll's cursor encoding so the frontend pattern Uses the shared `pagination` cursor (base64 of "<iso8601_sort_key>|<id>") so
is identical: base64 of "<iso8601_sort_key>|<post_id>". Sort key is every feed paginates identically. Sort key here is COALESCE(Post.post_date,
COALESCE(Post.post_date, Post.downloaded_at) so posts without a Post.downloaded_at) so posts without a publish date sort by when we captured them.
publish date sort by when we captured them.
Pure read-surface; no writes. The service composes the post dict Pure read-surface; no writes. The service composes the post dict
(thumbnails from every image linked to the post — its own primary images (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 from __future__ import annotations
import base64
from datetime import datetime
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,26 +24,12 @@ from ..models import (
) )
from ..utils.text import html_to_plain, truncate_at_word from ..utils.text import html_to_plain, truncate_at_word
from .gallery_service import thumbnail_url from .gallery_service import thumbnail_url
from .pagination import decode_cursor, encode_cursor
CURSOR_SEPARATOR = "|"
DESCRIPTION_LIMIT = 280 DESCRIPTION_LIMIT = 280
THUMBNAIL_LIMIT = 6 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(): def _sort_key():
"""Postgres COALESCE expression used in ORDER BY and WHERE clauses.""" """Postgres COALESCE expression used in ORDER BY and WHERE clauses."""
return func.coalesce(Post.post_date, Post.downloaded_at) return func.coalesce(Post.post_date, Post.downloaded_at)