Skip to content

refactor(messages): page the feed by opaque cursor instead of offset (#22) - #96

Merged
Adron merged 1 commit into
parity/queuefrom
issue/22-cursor-pagination
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/22-cursor-pagination

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #22. Part of epic #17.

Why this matters more than it looks

The live spec declares exactly four query parameters on GET /api/messages: limit, onlyMine,
tag, cursor. There is no offset. So the app's limit/offset paging was sending a
parameter the server does not document — and a confirmed live call returns
pagination: { total, offset, limit, hasMore, nextCursor } with nextCursor as an opaque base64
keyset token. This was drift waiting to happen on exactly the workload a social feed hits
constantly: new posts arriving at the head between page fetches.

What changed

Mirrors the :feature:directmessages shape, so the two modules now page identically — repository
takes a cursor and returns the next one, the caller holds it, null means end of list.

  • MessagesApi.getMessages takes @Query("cursor") cursor: String? = null instead of offset.
    Retrofit omits it entirely for page 1, so offset never appears on the wire again.
  • PaginationDto gained nextCursor; MessagesResponse exposes it (blank treated as absent) and
    a rows accessor reading from either data or messages, matching ConversationsResponse.rows.
  • refreshFeed(): ApiResult<String?> / loadMoreFeed(cursor): ApiResult<String?> replace the
    Boolean hasMore / currentCount offset pair. The cursor is only ever stored and handed back
    — never parsed, sliced, built or compared.
  • MessagesFeedViewModel holds it in the existing FeedTransientState; canLoadMore derives from
    nextCursor != null; refresh() nulls it so paging restarts from the top.
  • Room behaviour unchanged: refresh clears + inserts head, load-more appends at maxFeedOrder + 1,
    rows keyed by id so a repeat updates in place.

Search is deliberately left alone: GET /api/messages/search declares only q, limit and
onlyMine — there is no cursor to pass back.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 727 tests, 0 failures.

The regression the issue is really about is covered by a MockWebServer Dispatcher that serves the
offset-drifted page (duplicate m3, skipped m1) if an offset query ever appears — so a
regression to offset paging is caught by the row assertions and by an explicit "no offset
parameter" assertion. Mutation-checked: dropping the cursor from loadMoreFeed makes both a post arriving at the head between pages neither duplicates nor skips rows and page two hands the cursor back verbatim and sends no offset fail, and both pass again on restore.

Reviewer note — a pre-existing bug found but NOT fixed here

DefaultMessagesRepository.createMessage places a new post at maxFeedOrder() - 1. The feed sorts
by feedOrder ASC, so with more than two cached rows a freshly created message lands mid-feed
rather than at the top
. It needs a minFeedOrder() DAO query. The existing test only covers a
single-row feed, which is why it passes today. Left out of scope because it sits in composer code
that #19/#20 will touch — filed separately so it does not get lost.

Also unrelated and untouched: GET /api/messages/{id}/replies returns rows under replies, which
MessagesResponse.rows does not read.

GET /api/messages declares limit/onlyMine/tag/cursor and no offset at all, so
the feed's limit/offset paging was sending a parameter the server ignores: page
2 re-served the head and drifted whenever a new post arrived between pages.

Switch the feed to the documented cursor/nextCursor contract, mirroring the
shape :feature:directmessages already uses for /api/dm/conversations:

- MessagesApi.getMessages takes an optional `cursor` instead of `offset`;
  Retrofit omits it for the first page.
- PaginationDto carries `nextCursor`, surfaced as MessagesResponse.nextCursor
  (blank counts as absent). The envelope now also accepts rows under the
  documented `messages` key as well as `data`, read through `rows`.
- MessagesRepository.refreshFeed() returns the next cursor and loadMoreFeed()
  takes one, replacing the boolean hasMore / currentCount offset pair. The
  cursor is opaque: it is stored and handed back verbatim, never parsed,
  constructed or compared.
- The feed ViewModel holds the cursor in its existing paging state; a
  refresh-from-top clears it, and a null cursor ends the feed.

Room stays the source of truth: a refresh still replaces the head, a page
append still writes to the tail, and rows keyed by id cannot duplicate.

Message search is left on its current paging: the spec's /api/messages/search
declares only q/limit/onlyMine, with no cursor to pass back.

Tests: a new MessagesFeedCursorPagingTest pins that page 1 sends no cursor and
no offset, that page 2 echoes the cursor verbatim, that a post arriving at the
head between pages neither duplicates nor skips rows, that a null nextCursor
ends the feed, and that a refresh resets the cursor; MessagesResponseTest pins
the envelope/cursor parsing; the feed ViewModel tests cover cursor threading
and the reset on refresh.

Closes #22
@Adron
Adron merged commit eaeb5d0 into parity/queue Sep 16, 2026
1 check passed
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.

1 participant