refactor(services): shared pagination cursor (DRY sweep)
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:
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user