Files
pad/internal/server/activity_cursor.go
T
xarmian 8c7141a0ae fix(activity): feeds page by a (created_at, id) keyset and break timestamp ties by id (BUG-2781) (#1459)
* fix(activity): feeds page by a (created_at, id) keyset and break timestamp ties by id (BUG-2781)

Activity rows move: a debounce merge restamps an older row to now, so under
offset paging the next page repeated a row the client held (and every feed
renders a keyed each, where a repeated key is a runtime error). The feeds
also ordered by a whole-second column with no tie-break, so rows sharing a
second had no defined order.

- Store: `, id DESC` tie-break on the workspace, document and audit feeds
  (the user feed had it) and on mcp_audit_log's three lists; a shared keyset
  predicate on all four activity feeds.
- HTTP: before + before_id (both or neither, RFC3339) on the workspace,
  document, admin-user and audit-log doors; offset still accepted alone for
  a deprecation window, and refused in combination. The admin envelope gains
  additive next_before / next_before_id.
- Web: the activity page, admin user tab and audit log page page by cursor
  and dedupe by id on append.

Not fixed by any paging scheme, and pinned: the MOVED row itself is ahead of
every cursor and appears only when the head of the feed is re-read.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(activity): the item door takes the cursor, the activity page drops superseded responses, and a stale comment is corrected (BUG-2781, codex round 1)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 09:05:05 -04:00

42 lines
1.8 KiB
Go

package server
import (
"net/http"
"time"
)
// activityCursorFromQuery reads the keyset cursor the paginated activity
// feeds accept (BUG-2781): before=<created_at, RFC3339> and before_id=<id>,
// both taken by the client from the LAST row it already holds. It writes a
// 400 and returns ok=false on a malformed cursor.
//
// BOTH OR NEITHER. A timestamp alone skips the rest of a run of rows sharing
// that second (created_at is stored at whole-second precision), and an id
// alone orders nothing. Refused rather than degraded, because either half on
// its own pages silently wrong.
//
// NEVER WITH offset. Offset stays accepted on its own through a deprecation
// window, for callers that have not moved; combining the two has no single
// meaning, so it is refused rather than one of them being picked.
func activityCursorFromQuery(w http.ResponseWriter, r *http.Request, offset int) (before time.Time, beforeID string, ok bool) {
q := r.URL.Query()
rawBefore, rawID := q.Get("before"), q.Get("before_id")
if rawBefore == "" && rawID == "" {
return time.Time{}, "", true
}
if rawBefore == "" || rawID == "" {
writeError(w, http.StatusBadRequest, "invalid_cursor", "`before` and `before_id` must be sent together: both come from the last row of the previous page")
return time.Time{}, "", false
}
if offset > 0 {
writeError(w, http.StatusBadRequest, "invalid_cursor", "`offset` cannot be combined with `before`/`before_id`; page with the cursor alone")
return time.Time{}, "", false
}
t, err := time.Parse(time.RFC3339Nano, rawBefore)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_cursor", "`before` must be an RFC3339 timestamp, the `created_at` of the last row of the previous page")
return time.Time{}, "", false
}
return t, rawID, true
}