Skip to content

feat(api): cursor-paginate the course list (issue #73) - #311

Open
longobucco wants to merge 3 commits into
masterfrom
feat/course-list-pagination
Open

longobucco wants to merge 3 commits into
masterfrom
feat/course-list-pagination

Conversation

@longobucco

@longobucco longobucco commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

What changed and why

GET /api/courses was offset/limit with no ORDER BY at all, so result order wasn't even
guaranteed. 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:

  • Course had no created_at column — migration 0009_course_created_at adds it (backfilled to
    a 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 head
    and Base.metadata.create_all() stay in sync.
  • New reusable CursorPage envelope (app/schemas/pagination.py) — {items, next_cursor, has_more}, opaque base64 cursor — meant to be reused by the document/session-list follow-ups
    mentioned in the issue.
  • Response shape change on GET /api/courses: breaking, but the only consumer is this repo's own
    frontend, updated in the same branch.

Frontend:

  • getCourses moves from (skip, limit) → Course[] to (cursor, limit) → CoursePage.
  • The course grid loads 20 at a time and appends further pages via a "Load more" control
    (preserves scroll position naturally, since it only adds content).
  • Removed the MVP_COURSES_LIMIT constant and its comment — the debt it documented is resolved.

docs/specifications.md updated 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 CursorPage convention applies
directly). 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

  • Backend: cd services/ai && uv run --no-sync pytest — 1100 passed, 85.38% coverage (ratchet
    76%). uv run --no-sync mypy app — clean, 88 source files.
  • Frontend: cd apps/web && npm run type-check && npm run lint && npm test — clean, 32 suites /
    299 tests passed.
  • New integration tests (test_courses_pagination_api.py) specifically cover: newest-first
    ordering, full traversal without duplicates/gaps across pages, the exact-limit boundary
    (has_more must be False, not a false-positive extra page), id tie-breaking when
    created_at is identical (what every backfilled row hits), inactive courses staying excluded
    across 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.
  • Independent review pass (opus) on the full branch diff before this PR: no Critical findings;
    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 frontend fetchCourses helper).

Things reviewers should watch for

  • The course-count/documents header in apps/web/src/app/courses/page.tsx now describes only the
    loaded 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 total field is a natural
    follow-up if this needs fixing, flagged rather than silently shipped.
  • Course.created_at follows this codebase's existing pattern (default=func.now() on a
    String column, explicit ISO-8601 override at the one call site that creates rows) — same
    latent risk as Quiz.created_at already has if a future code path ever constructs a Course
    directly instead of through course_repo.create_course. Not introduced by this PR, not fixed
    here, consistent with the existing convention.
  • No per-user ownership filter exists on this list endpoint today (pre-existing, unrelated to this
    PR) — every authenticated user sees every active course, same as on master.

longobucco 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
@longobucco
longobucco requested a review from lucaosti November 6, 2026 09:34

@lucaosti lucaosti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) raises RecursionError in decode_cursor, so it still reaches the 500 handler. Could you add a max_length on the cursor query parameter and catch that case too? Validating created_at with datetime.fromisoformat would also reject odd values such as embedded NUL bytes, which Postgres would refuse.
  • scripts/seed_demo.sh:98 still reads the list as a plain array (jq '.[] | ...'). It needs .items[] now.
  • Migration numbering: #312 also adds revision 0009 on top of 0008, and with both merged alembic sees two heads. Whichever lands second needs to become 0010 with down_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants