Files
pad/internal/server/handlers_changes.go
T
xarmian 7ef4506cfa fix: replace scattered tab-resume refetches with layered sync system
When the browser tab lost focus and regained it, 5 independent
onTabResume callbacks all fired simultaneously, flooding the server
with redundant requests. This replaces that pattern with a 4-layer
sync architecture:

1. Replay buffer — per-workspace ring buffer stores recent events with
   monotonic IDs. On SSE reconnect, missed events are replayed via
   Last-Event-ID so the client is already caught up.

2. Last-Event-ID support — SSE handler reads the header, replays from
   buffer, or sends sync_required if the gap is too large.

3. Incremental sync — new /changes?since=<ms> endpoint returns only
   modified/deleted items since a timestamp, including archived items
   for view consistency.

4. Centralized sync coordinator — single decision tree replaces 5
   scattered callbacks. Short absences skip sync entirely, SSE-covered
   gaps need no API calls, and full refresh is a last resort.

Key robustness details:
- Global event IDs via Redis INCR for multi-instance safety
- Server-time cursors to avoid client clock skew
- Safe cursor management (only advances on confirmed sync)
- 9 new tests for replay buffer and event ID behavior

Fixes BUG-26.
2026-04-10 04:15:43 +00:00

78 lines
2.3 KiB
Go

package server
import (
"net/http"
"strconv"
"time"
)
// ChangesResponse is the response for GET /workspaces/{ws}/changes?since=<unix_ms>.
type ChangesResponse struct {
// Updated items (with full item data).
Updated []interface{} `json:"updated"`
// IDs of items that were deleted since the requested timestamp.
Deleted []string `json:"deleted"`
// Server timestamp at the time of this response (unix ms).
// Clients should use this as the `since` value for the next sync.
ServerTime int64 `json:"server_time"`
// Whether the collection metadata (counts, schemas) may have changed.
// True if any items were updated/deleted, signaling the client should
// also refresh collection metadata.
CollectionsChanged bool `json:"collections_changed"`
}
// handleGetChanges returns items modified since a given timestamp.
// GET /api/v1/workspaces/{ws}/changes?since=<unix_milliseconds>
//
// This is the incremental sync endpoint used by the frontend when the
// tab regains focus. Instead of re-fetching everything, the client sends
// the timestamp of its last successful sync and gets back only the delta.
func (s *Server) handleGetChanges(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
sinceStr := r.URL.Query().Get("since")
if sinceStr == "" {
writeError(w, http.StatusBadRequest, "bad_request", "since query parameter is required (unix milliseconds)")
return
}
sinceMs, err := strconv.ParseInt(sinceStr, 10, 64)
if err != nil || sinceMs < 0 {
writeError(w, http.StatusBadRequest, "bad_request", "since must be a valid unix timestamp in milliseconds")
return
}
since := time.UnixMilli(sinceMs)
serverTime := time.Now().UnixMilli()
updated, deletedIDs, err := s.store.ItemsModifiedSince(workspaceID, since)
if err != nil {
writeInternalError(w, err)
return
}
// Convert to interface slice for JSON marshaling.
updatedItems := make([]interface{}, len(updated))
for i, item := range updated {
updatedItems[i] = item
}
if updatedItems == nil {
updatedItems = []interface{}{}
}
if deletedIDs == nil {
deletedIDs = []string{}
}
resp := ChangesResponse{
Updated: updatedItems,
Deleted: deletedIDs,
ServerTime: serverTime,
CollectionsChanged: len(updated) > 0 || len(deletedIDs) > 0,
}
writeJSON(w, http.StatusOK, resp)
}