Repository navigation
feat(api): cursor-paginate the course list (issue #73) - #311
Open
longobucco wants to merge 3 commits into
Open
longobucco wants to merge 3 commits into
longobucco wants to merge 3 commits into
Conversation
added 3 commits
November 6, 2026 00:51
GET /api/courses was unpaginated offset/limit with no ORDER BY at all,
so result order wasn't even guaranteed. Replace it with keyset
pagination on (created_at, id), newest first: a cursor stays stable
under concurrent inserts, where an offset page can skip or repeat rows
once the underlying set changes between requests.
Course had no created_at column; migration 0009 adds it (backfilled to
a fixed past sentinel for existing rows, since their real creation
order was never recorded) plus a composite index matching the new
keyset query, and the SQLAlchemy model declares the same index so
`alembic upgrade head` and `Base.metadata.create_all()` stay in sync
(test_alembic_head_matches_models catches drift between them).
The response becomes an envelope {items, next_cursor, has_more} via a
new, reusable CursorPage schema — a breaking response-shape change,
acceptable since the only consumer is this repo's own frontend,
updated in the same branch.
Refs #73
Follows the backend's new cursor-paginated GET /api/courses. Removes
the MVP_COURSES_LIMIT constant and its comment, now that the debt it
documented is resolved — the page fetches DEFAULT_COURSES_PAGE_SIZE
(20) courses up front and appends further pages on demand via a "Load
more" control, which preserves scroll position naturally since it only
adds content rather than replacing it.
getCourses's signature changes from (skip, limit) to (cursor, limit)
and its return type from Course[] to a CoursePage envelope
({items, nextCursor, hasMore}) — the only caller (courses/page.tsx) is
updated in this same commit.
Refs #73
- decode_cursor now rejects any malformed shape (missing fields, wrong types, non-dict JSON) as ValueError instead of letting KeyError/ TypeError reach the generic 500 handler; cursor decoding moves to the controller so an unrelated ValueError elsewhere can't be mislabelled "Invalid cursor." - courses/page.tsx's initial-load effect depended on the whole session object, so next-auth's refetchOnWindowFocus (a new session object on every tab focus) wiped out pages already loaded via "Load more" and reset scroll position. Depend on the access token value instead. - A failed "load more" request no longer hides every course already shown — it shows an inline retry next to the button instead of tripping the page-level error view. - New course now prepends (list is newest-first) instead of appending. - Removed now-dead course_service.list_courses/course_repo.get_all_courses and the stale, uncalled apps/web/src/lib/api/courses.ts::fetchCourses. - Replaced an assert (strips under python -O) with an explicit check in list_courses_page's cursor-encoding path. - Added tests: exact-limit boundary (has_more must be False), id tie-break under identical created_at (what every backfilled row hits), inactive courses staying excluded across pages, and six malformed-cursor shapes that previously 500'd. Bumped the seeded dataset from 25 to 60 courses to actually be the order-of-magnitude increase the issue's acceptance criterion asks for. Refs #73
lucaosti
requested changes
Nov 10, 2026
lucaosti
left a comment
Collaborator
There was a problem hiding this comment.
Thanks Luca, this is a really clean piece of work. The tie-break tests and the "insert between pages" test are exactly what I wanted to see, and I checked them myself: paging over identical sentinel created_at values with limit 1 and 2, and on a database migrated from 0008, gives no skips and no duplicates, and EXPLAIN QUERY PLAN uses the new index.
Before we merge:
- A deeply nested JSON cursor (for example base64 of a thousand
[characters) raisesRecursionErrorindecode_cursor, so it still reaches the 500 handler. Could you add amax_lengthon thecursorquery parameter and catch that case too? Validatingcreated_atwithdatetime.fromisoformatwould also reject odd values such as embedded NUL bytes, which Postgres would refuse. scripts/seed_demo.sh:98still reads the list as a plain array (jq '.[] | ...'). It needs.items[]now.- Migration numbering: #312 also adds revision
0009on top of0008, and with both merged alembic sees two heads. Whichever lands second needs to become0010withdown_revision = "0009".
Optional: setting the model default to the same UTC ISO lambda as create_course would close the mixed-format risk you already flagged, and apps/web/README.md:131 still shows the old array response.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed and why
GET /api/courseswas offset/limit with noORDER BYat all, so result order wasn't evenguaranteed. This resolves issue #73 (scoped to the course list only — the issue's other two
surfaces, document lists and study-session lists, are explicit follow-up work) by switching to
cursor (keyset) pagination on
(created_at, id), newest first.Cursor rather than offset specifically because the issue calls it out: an offset page can skip or
repeat rows when the underlying set changes between requests; a keyset cursor can't, since it's
always relative to a specific row rather than a position.
Backend:
Coursehad nocreated_atcolumn — migration0009_course_created_atadds it (backfilled toa fixed sentinel for existing rows, since their real creation order was never recorded) plus a
composite index, and the SQLAlchemy model declares the matching index so
alembic upgrade headand
Base.metadata.create_all()stay in sync.CursorPageenvelope (app/schemas/pagination.py) —{items, next_cursor, has_more}, opaque base64 cursor — meant to be reused by the document/session-list follow-upsmentioned in the issue.
GET /api/courses: breaking, but the only consumer is this repo's ownfrontend, updated in the same branch.
Frontend:
getCoursesmoves from(skip, limit)→Course[]to(cursor, limit)→CoursePage.(preserves scroll position naturally, since it only adds content).
MVP_COURSES_LIMITconstant and its comment — the debt it documented is resolved.docs/specifications.mdupdated under requirement 1 to describe the new behavior, per AGENTS.md.Issue
Refs #73. Not closing it — the issue also covers document lists and study-session lists, which
this PR deliberately leaves open as separate follow-up work (same
CursorPageconvention appliesdirectly). The trigger the issue names (50+ courses/user, or a measured page-load threshold) had
not been hit; built proactively ahead of it per discussion with the repo owner.
How it was verified
cd services/ai && uv run --no-sync pytest— 1100 passed, 85.38% coverage (ratchet76%).
uv run --no-sync mypy app— clean, 88 source files.cd apps/web && npm run type-check && npm run lint && npm test— clean, 32 suites /299 tests passed.
test_courses_pagination_api.py) specifically cover: newest-firstordering, full traversal without duplicates/gaps across pages, the exact-limit boundary
(
has_moremust beFalse, not a false-positive extra page), id tie-breaking whencreated_atis identical (what every backfilled row hits), inactive courses staying excludedacross pages, six malformed-cursor shapes that must 422 rather than 500, and — the issue's own
stated reason for preferring cursor over offset — a course inserted between two page requests
not shifting or duplicating rows the first page already returned.
all Important findings (crafted cursors reaching the generic 500 handler instead of a 422;
course-list state getting wiped by next-auth's session refetch-on-focus and losing "Load more"
progress; a failed "load more" hiding the entire grid instead of retrying inline) fixed in a
follow-up commit, along with the missing boundary/tie-break/malformed-cursor tests it flagged
and some cheap dead-code cleanup (unused
course_service.list_courses/course_repo.get_all_courses, an unused frontendfetchCourseshelper).Things reviewers should watch for
apps/web/src/app/courses/page.tsxnow describes only theloaded courses, not the true total, once "Load more" is in play (e.g. "20 courses" when 60
exist). Left as-is since the envelope has no total count today — a
totalfield is a naturalfollow-up if this needs fixing, flagged rather than silently shipped.
Course.created_atfollows this codebase's existing pattern (default=func.now()on aStringcolumn, explicit ISO-8601 override at the one call site that creates rows) — samelatent risk as
Quiz.created_atalready has if a future code path ever constructs aCoursedirectly instead of through
course_repo.create_course. Not introduced by this PR, not fixedhere, consistent with the existing convention.
PR) — every authenticated user sees every active course, same as on master.