mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
8c7141a0ae
* 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
42 lines
1.8 KiB
Go
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
|
|
}
|