fix(sync): fix SSE live updates — 2 bugs found during E2E testing

Bug 1: NotifyUser() early-returned when no WebSocket clients existed,
skipping SSE subscriber notifications entirely. Fixed by restructuring
to check WS clients and SSE subscribers independently.

Bug 2: SSE stream cursor defaulted to client's `since` parameter when
no initial data existed. If `since` was in the future (or very recent),
live updates had created_at < cursor and were silently filtered out.
Fixed by defaulting cursor to now() when no initial data is returned.

Bug 3: NotifyUser used original sseSubs slice instead of sseSubsCopy
after releasing the read lock (race condition).

Verified E2E: Push from client A → SSE stream on client B receives
live change event with correct data within ~1 second.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 23:39:46 +02:00
parent 4cb1bda852
commit fed38efb8b
2 changed files with 41 additions and 38 deletions

View file

@ -164,7 +164,7 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
SyncedUntil: now,
}
// Notify other connected clients via WebSocket
// Notify other connected clients via WebSocket/SSE
if len(affectedTables) > 0 {
tables := make([]string, 0, len(affectedTables))
for t := range affectedTables {
@ -326,7 +326,8 @@ func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request) {
}
}
// Track cursors per collection
// Track cursors per collection — default to now() if no initial data
now := time.Now().UTC().Format(time.RFC3339Nano)
cursors := make(map[string]string)
for _, coll := range collections {
cursors[coll] = since
@ -337,6 +338,7 @@ func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request) {
changes, err := h.store.GetChangesSince(ctx, userID, appID, coll, since, clientID, batchLimit+1)
if err != nil {
slog.Error("SSE initial pull failed", "error", err, "collection", coll)
cursors[coll] = now // Default to now on error
continue
}
@ -350,6 +352,9 @@ func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request) {
cursors[coll] = cursor
sendChangeEvent(w, coll, h.convertChanges(changes), cursor, hasMore)
flusher.Flush()
} else {
// No initial data — set cursor to now so live updates work
cursors[coll] = now
}
}