ADR-0021: Activity streams use cursor pagination
ODD Platform paginates its append-only activity streams by a (lastEventId, lastEventDateTime) cursor rather than offset/limit — list endpoints keep offset paging; the shape follows the data.
Status
Accepted. Reconstructed from the codebase on 2026-05-30; the decision is live in the source today.
Context
The activity feed is an append-only audit trail that can grow very large. Two pagination shapes are available: offset/limit (page + size), which the platform uses for aggregate listings like alerts, and cursor pagination, which carries a pointer to the last item seen. Offset paging degrades as the offset grows — the database must scan and discard every skipped row — which is exactly the wrong cost profile for deep scrolling through a long audit log.
Decision
Activity-stream endpoints paginate by a (lastEventId, lastEventDateTime) cursor, not offset/limit. The caller passes the id and timestamp of the last event it saw, plus a size; the next page is the rows immediately after that cursor. The same cursor shape is reused across the platform-wide activity feed (getActivity) and the per-data-entity activity feed (getDataEntityActivityList), establishing cursor pagination as the convention for activity streams specifically.
This is not a global pagination convention: aggregate/mutable listings (alerts, the data-entity list) continue to use offset/limit (page + size). The pagination shape follows the data semantics — append-only audit streams get cursors; bounded mutable listings get offsets.
Consequences
Deep scrolling through the activity feed stays cheap: each page is an indexed seek past the cursor rather than an offset scan, so cost does not grow with how far back the reader has paged.
The cursor is a compound key
(lastEventId, lastEventDateTime)because event timestamps are not unique — the id breaks ties so the page boundary is deterministic.A reader cannot jump to "page N" of the activity feed (there are no page numbers) — only forward/continued paging from a cursor. That is the accepted trade-off of the shape, and it matches how an audit feed is actually read.
There is a known naming wart: the controller-level cursor parameter is spelled
lasEventId(missing a "t"), so the generated client exposes that misspelling on the public contract even though the service-layer parameter is the correctlastEventId. The decision is the cursor shape; the misspelled parameter name is a contract blemish that renaming would be a breaking change to fix.
Evidence
odd-platform-api/.../controller/ActivityController.java:34-35—getActivitytakesfinal Long lasEventId, final OffsetDateTime lastEventDateTime(the cursor pair; note thelasEventIdmisspelling at the controller/contract layer) and passes them to the service.odd-platform-api/.../service/activity/ActivityServiceImpl.java:96-97—getActivityList(... final Long lastEventId, final OffsetDateTime lastEventDateTime);:179-180—fetchAllActivitiesforwards them toactivityRepository.findAllActivities(... lastEventId, lastEventDateTime).odd-platform-api/.../service/activity/ActivityServiceImpl.java:119-127—getDataEntityActivityListtakes the same(lastEventId, lastEventDateTime)cursor pair, confirming the shape is reused across activity-stream endpoints.
See also
Activity Feed — the user-facing activity stream this paginates.
ADR-0022 — Activity view-modes are a single enum parameter — the companion API-shape decision for the same endpoint.
Last updated