gabriel / musehub public
pagination.py python
137 lines 5.4 KB
Raw
sha256:8a1389ae62d0a688d5e027763249bd8faedfc39ab00857d65da6620d1ab8a689 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 3 days ago
1 """Cursor-based pagination helpers for MuseHub list endpoints.
2
3 All list endpoints share a single, uniform pagination contract:
4
5 - **Query params** — ``?cursor=<opaque>&limit=N``
6 - **Response body** — every list response includes ``next_cursor: str | null``
7 and ``total: int``
8 - **Response header** — ``Link: <url>; rel="next"`` when more pages exist
9
10 The cursor is an opaque string. Clients must not interpret or construct it —
11 only echo it back verbatim as ``?cursor=``. A null (or absent) ``next_cursor``
12 means the caller has reached the last page.
13
14 Why cursor-based over page-based
15 ---------------------------------
16 Page numbers drift when items are inserted or deleted mid-session — item N
17 may appear on page 3 in one request and page 4 in the next. Cursor-based
18 keyset pagination anchors each page to a stable position in the ordering
19 sequence, so agents that paginate through large datasets always see a
20 consistent, non-duplicating stream regardless of concurrent mutations.
21
22 Page-based pagination also requires fetching all rows up to the requested
23 offset at the database level (``OFFSET N`` scans from row 0). Keyset
24 pagination issues ``WHERE ordering_col > cursor LIMIT N``, which is O(1)
25 regardless of how deep into the list the cursor points.
26
27 Why pagination metadata belongs in the response body
28 -----------------------------------------------------
29 RFC 8288 Link headers are the HTTP-standard way to advertise pagination
30 links, but they are invisible to most agent tool-call environments — agents
31 receive the response body, not raw HTTP headers. Putting ``next_cursor``
32 in the body means an agent can consume it directly without any
33 ``curl -I`` magic. The Link header remains for HTTP-native clients (curl,
34 browsers, OpenAPI tooling).
35
36 Usage example
37 -------------
38 ::
39
40 @router.get("/repos/{repo_id}/issues")
41 async def list_issues(
42 request: Request,
43 response: Response,
44 pagination: PaginationParams = Depends(PaginationParams),
45 db: AsyncSession = Depends(get_db),
46 ) -> IssueListResponse:
47 result = await svc.list_issues(
48 db, repo_id,
49 cursor=pagination.cursor,
50 limit=pagination.limit,
51 )
52 if result.next_cursor is not None:
53 response.headers["Link"] = build_cursor_link_header(
54 request, result.next_cursor, pagination.limit
55 )
56 return result
57 """
58
59 from urllib.parse import urlencode
60
61 from fastapi import Query, Request
62
63
64 class PaginationParams:
65 """FastAPI dependency that extracts cursor-based pagination query params.
66
67 Inject via ``Depends(PaginationParams)`` in any list route handler.
68
69 All MuseHub list endpoints use a single pagination contract:
70 ``?cursor=<opaque>&limit=N``. Pass the ``nextCursor`` value from a
71 previous response as ``?cursor=`` to advance to the next page. A null
72 ``nextCursor`` in the response body means you are on the last page.
73
74 Params
75 ------
76 cursor
77 Opaque string from the ``nextCursor`` field of a previous response.
78 Omit (or pass ``null``) to start from the beginning.
79 limit
80 Maximum items to return in this page. Capped at 200 to prevent
81 unbounded responses. The default of 20 suits interactive use;
82 agents reducing round-trips should raise it toward 100–200.
83 """
84
85 def __init__(
86 self,
87 cursor: str | None = Query(
88 None,
89 description=(
90 "Opaque pagination cursor from a previous response. "
91 "Pass the nextCursor field verbatim to advance to the next page. "
92 "Omit to start from the beginning."
93 ),
94 ),
95 limit: int = Query(
96 20,
97 ge=1,
98 le=200,
99 description=(
100 "Maximum number of items to return (1–200, default 20). "
101 "Agents reducing round-trips should raise this toward 100–200."
102 ),
103 ),
104 ) -> None:
105 self.cursor = cursor
106 self.limit = limit
107
108
109 def build_cursor_link_header(request: Request, next_cursor: str, limit: int) -> str:
110 """Build an RFC 8288 ``Link`` header with ``rel="next"`` for cursor pagination.
111
112 Emits exactly one link — ``rel="next"`` — because cursor-based pagination
113 cannot derive ``rel="prev"``, ``rel="first"``, or ``rel="last"`` without
114 an additional full-table scan. The ``next_cursor`` in the response body
115 is the canonical signal for agents; this header is supplementary for
116 HTTP-native clients.
117
118 All existing query parameters on the request URL are preserved; only
119 ``cursor`` and ``limit`` are overridden, so filters like ``?state=open``
120 are carried forward automatically on the next-page URL.
121
122 Args:
123 request: The current FastAPI ``Request``. Used to extract the
124 base URL and existing query parameters.
125 next_cursor: Opaque cursor value from the service layer.
126 limit: The page size used for this request — preserved on the
127 generated URL so the client stays at the same page size.
128
129 Returns:
130 A single RFC 8288 link string, e.g.
131 ``<https://host/api/repos/x/issues?state=open&cursor=abc&limit=20>; rel="next"``
132 """
133 base_url = str(request.url).split("?")[0]
134 existing_params = dict(request.query_params)
135 q = {**existing_params, "cursor": next_cursor, "limit": str(limit)}
136 next_url = f"{base_url}?{urlencode(q)}"
137 return f'<{next_url}>; rel="next"'
File History 15 commits
sha256:8a1389ae62d0a688d5e027763249bd8faedfc39ab00857d65da6620d1ab8a689 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 3 days ago
sha256:bee12c5cbde2334f98421c6c209d768fa6b8004d6705c9ea798ce6c1651bc11f Merge 'infra/database-phase3-4-cleanup' into 'dev' — propos… Human 4 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 67 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 70 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 73 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 75 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 88 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 92 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 93 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 94 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 94 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 94 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 94 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 106 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 108 days ago