diff --git a/internal/events/bus.go b/internal/events/bus.go index fe056703..38ad846d 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -3,6 +3,7 @@ package events import ( "log/slog" "sync" + "sync/atomic" "time" ) @@ -32,8 +33,15 @@ const ( ItemUpdatedWithComment = "item_updated_with_comment" ) +// Default replay buffer settings. +const ( + DefaultReplayBufferSize = 1024 // max events to retain per workspace + DefaultReplayMaxAge = 5 * time.Minute // discard events older than this +) + // Event represents a real-time event published when state changes occur. type Event struct { + ID int64 `json:"id"` Type string `json:"type"` WorkspaceID string `json:"workspace_id"` DocumentID string `json:"document_id,omitempty"` @@ -66,6 +74,11 @@ type EventBus interface { // Publish sends an event to all subscribers for the event's workspace. Publish(event Event) + // EventsSince returns events for a workspace with IDs greater than sinceID. + // Used to replay missed events on SSE reconnect (Last-Event-ID). + // Returns nil if sinceID is too old and has been evicted from the buffer. + EventsSince(workspaceID string, sinceID int64) []Event + // Close shuts down the event bus and cleans up resources. Close() @@ -77,6 +90,77 @@ type EventBus interface { WorkspaceSubscriberCount(workspaceID string) int } +// replayBuffer is a bounded ring buffer of recent events for a single workspace. +// It supports efficient append and replay-since-ID queries. +type replayBuffer struct { + events []Event + size int // max capacity + head int // next write position + count int // current number of events +} + +func newReplayBuffer(size int) *replayBuffer { + return &replayBuffer{ + events: make([]Event, size), + size: size, + } +} + +// append adds an event to the ring buffer, evicting the oldest if full. +func (rb *replayBuffer) append(e Event) { + rb.events[rb.head] = e + rb.head = (rb.head + 1) % rb.size + if rb.count < rb.size { + rb.count++ + } +} + +// since returns all buffered events with ID > sinceID, in chronological order. +// Returns nil if sinceID is older than the oldest buffered event AND the buffer +// is full (i.e. events have been evicted), meaning we can't guarantee completeness. +// Returns an empty (non-nil) slice if sinceID is current (no missed events). +// A sinceID of 0 means "give me everything in the buffer". +func (rb *replayBuffer) since(sinceID int64) []Event { + if rb.count == 0 { + return []Event{} + } + + // Find the oldest event in the buffer + oldest := (rb.head - rb.count + rb.size) % rb.size + oldestID := rb.events[oldest].ID + + // Find the newest event in the buffer. + newest := (rb.head - 1 + rb.size) % rb.size + newestID := rb.events[newest].ID + + // If sinceID is beyond the newest event we have, the ID came from a + // different sequence (e.g., a different instance in a Redis deployment). + // We can't determine what was missed — signal a gap. + if sinceID > newestID { + return nil + } + + // If the requested ID is older than our oldest AND the buffer has wrapped + // (events were evicted), we can't guarantee completeness — signal a gap. + // But if the buffer hasn't filled up yet, all events are still present. + if sinceID > 0 && sinceID < oldestID && rb.count == rb.size { + return nil + } + + // Collect events with ID > sinceID + var result []Event + for i := 0; i < rb.count; i++ { + idx := (oldest + i) % rb.size + if rb.events[idx].ID > sinceID { + result = append(result, rb.events[idx]) + } + } + if result == nil { + result = []Event{} + } + return result +} + // subscriber wraps a channel with its workspace filter. type subscriber struct { ch chan Event @@ -88,12 +172,29 @@ type subscriber struct { type MemoryBus struct { mu sync.RWMutex subscribers map[chan Event]*subscriber + + // Monotonic sequence counter for event IDs. + seq atomic.Int64 + + // Per-workspace replay buffers for Last-Event-ID support. + replayMu sync.RWMutex + replayBuffers map[string]*replayBuffer + replaySize int + replayMaxAge time.Duration } -// New creates a new in-memory EventBus. +// New creates a new in-memory EventBus with default replay buffer settings. func New() *MemoryBus { + return NewWithReplay(DefaultReplayBufferSize, DefaultReplayMaxAge) +} + +// NewWithReplay creates a new in-memory EventBus with custom replay settings. +func NewWithReplay(bufferSize int, maxAge time.Duration) *MemoryBus { return &MemoryBus{ - subscribers: make(map[chan Event]*subscriber), + subscribers: make(map[chan Event]*subscriber), + replayBuffers: make(map[string]*replayBuffer), + replaySize: bufferSize, + replayMaxAge: maxAge, } } @@ -152,12 +253,27 @@ func (b *MemoryBus) Unsubscribe(ch chan Event) { // Publish sends an event to all subscribers for the event's workspace. // Non-blocking: if a subscriber's channel is full, the event is dropped -// and a warning is logged. +// and a warning is logged. Events are assigned a monotonic sequence ID +// and stored in the replay buffer for Last-Event-ID support. func (b *MemoryBus) Publish(event Event) { if event.Timestamp == 0 { event.Timestamp = time.Now().UnixMilli() } + // Assign a monotonic sequence ID. + event.ID = b.seq.Add(1) + + // Store in replay buffer for reconnect replay. + b.replayMu.Lock() + rb, ok := b.replayBuffers[event.WorkspaceID] + if !ok { + rb = newReplayBuffer(b.replaySize) + b.replayBuffers[event.WorkspaceID] = rb + } + rb.append(event) + b.replayMu.Unlock() + + // Fan out to live subscribers. b.mu.RLock() defer b.mu.RUnlock() @@ -173,6 +289,21 @@ func (b *MemoryBus) Publish(event Event) { } } +// EventsSince returns buffered events for a workspace with IDs greater than sinceID. +// Returns nil if sinceID has been evicted from the buffer (gap too large). +// Returns an empty slice if the caller is fully caught up. +func (b *MemoryBus) EventsSince(workspaceID string, sinceID int64) []Event { + b.replayMu.RLock() + defer b.replayMu.RUnlock() + + rb, ok := b.replayBuffers[workspaceID] + if !ok { + // No events ever published for this workspace. + return []Event{} + } + return rb.since(sinceID) +} + // Close shuts down the event bus by closing all subscriber channels. // SSE handler goroutines will see the channel close and exit cleanly. func (b *MemoryBus) Close() { diff --git a/internal/events/bus_test.go b/internal/events/bus_test.go index 7f19a7c7..21773612 100644 --- a/internal/events/bus_test.go +++ b/internal/events/bus_test.go @@ -275,3 +275,170 @@ func TestPublishNoSubscribers(t *testing.T) { WorkspaceID: "ws-1", }) } + +func TestEventIDsAreMonotonic(t *testing.T) { + bus := New() + ch := bus.Subscribe("ws-1") + defer bus.Unsubscribe(ch) + + for i := 0; i < 10; i++ { + bus.Publish(Event{ + Type: ItemUpdated, + WorkspaceID: "ws-1", + }) + } + + var lastID int64 + for i := 0; i < 10; i++ { + event := <-ch + if event.ID <= lastID { + t.Fatalf("event %d: ID %d not greater than previous %d", i, event.ID, lastID) + } + lastID = event.ID + } +} + +func TestEventsSinceCaughtUp(t *testing.T) { + bus := New() + + bus.Publish(Event{Type: ItemCreated, WorkspaceID: "ws-1"}) + bus.Publish(Event{Type: ItemUpdated, WorkspaceID: "ws-1"}) + bus.Publish(Event{Type: ItemUpdated, WorkspaceID: "ws-1"}) + + // Ask for events since the last one — should get empty slice + events := bus.EventsSince("ws-1", 3) + if events == nil { + t.Fatal("expected non-nil slice") + } + if len(events) != 0 { + t.Fatalf("expected 0 events, got %d", len(events)) + } +} + +func TestEventsSinceReplay(t *testing.T) { + bus := New() + + bus.Publish(Event{Type: ItemCreated, WorkspaceID: "ws-1"}) + bus.Publish(Event{Type: ItemUpdated, WorkspaceID: "ws-1"}) + bus.Publish(Event{Type: ItemArchived, WorkspaceID: "ws-1"}) + + // Ask for events since ID 1 — should get events 2 and 3 + events := bus.EventsSince("ws-1", 1) + if events == nil { + t.Fatal("expected non-nil slice") + } + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].Type != ItemUpdated { + t.Errorf("expected ItemUpdated, got %q", events[0].Type) + } + if events[1].Type != ItemArchived { + t.Errorf("expected ItemArchived, got %q", events[1].Type) + } +} + +func TestEventsSinceGapTooLarge(t *testing.T) { + // Create a tiny buffer so we can overflow it + bus := NewWithReplay(3, 5*time.Minute) + + // Publish 5 events (buffer only holds 3) + for i := 0; i < 5; i++ { + bus.Publish(Event{Type: ItemUpdated, WorkspaceID: "ws-1"}) + } + + // Oldest buffered event should be ID 3 (events 1 and 2 are evicted) + // Asking for events since ID 1 should return nil (gap too large) + events := bus.EventsSince("ws-1", 1) + if events != nil { + t.Fatalf("expected nil (gap too large), got %d events", len(events)) + } + + // Asking for events since ID 3 should work + events = bus.EventsSince("ws-1", 3) + if events == nil { + t.Fatal("expected non-nil slice") + } + if len(events) != 2 { + t.Fatalf("expected 2 events (IDs 4,5), got %d", len(events)) + } +} + +func TestEventsSinceWorkspaceIsolation(t *testing.T) { + bus := New() + + bus.Publish(Event{Type: ItemCreated, WorkspaceID: "ws-1"}) + bus.Publish(Event{Type: ItemCreated, WorkspaceID: "ws-2"}) + bus.Publish(Event{Type: ItemUpdated, WorkspaceID: "ws-1"}) + + // ws-2 events since 0 should only return the ws-2 event + events := bus.EventsSince("ws-2", 0) + if len(events) != 1 { + t.Fatalf("expected 1 event for ws-2, got %d", len(events)) + } + if events[0].WorkspaceID != "ws-2" { + t.Errorf("expected ws-2, got %s", events[0].WorkspaceID) + } +} + +func TestEventsSinceNoEventsForWorkspace(t *testing.T) { + bus := New() + + events := bus.EventsSince("ws-nonexistent", 0) + if events == nil { + t.Fatal("expected non-nil empty slice") + } + if len(events) != 0 { + t.Fatalf("expected 0 events, got %d", len(events)) + } +} + +func TestEventsSinceForeignID(t *testing.T) { + // Simulates the multi-instance Redis scenario: a client sends a + // Last-Event-ID from a different instance whose IDs are in a different range. + bus := New() + + bus.Publish(Event{Type: ItemCreated, WorkspaceID: "ws-1"}) + bus.Publish(Event{Type: ItemUpdated, WorkspaceID: "ws-1"}) + + // sinceID=500 is way beyond our newest (ID 2) — foreign sequence + events := bus.EventsSince("ws-1", 500) + if events != nil { + t.Fatalf("expected nil (foreign ID), got %d events", len(events)) + } +} + +func TestReplayBufferWrapAround(t *testing.T) { + bus := NewWithReplay(4, 5*time.Minute) + + // Fill buffer exactly + for i := 0; i < 4; i++ { + bus.Publish(Event{Type: ItemUpdated, WorkspaceID: "ws-1"}) + } + + // Overflow by 2 + bus.Publish(Event{Type: ItemCreated, WorkspaceID: "ws-1"}) + bus.Publish(Event{Type: ItemArchived, WorkspaceID: "ws-1"}) + + // Buffer should hold events 3,4,5,6 (1,2 evicted) + // sinceID=3 should work: events 4,5,6 + events := bus.EventsSince("ws-1", 3) + if events == nil { + t.Fatal("expected non-nil slice for sinceID=3") + } + if len(events) != 3 { + t.Fatalf("expected 3 events (IDs 4,5,6), got %d", len(events)) + } + + // sinceID=2 should be a gap (event 2 is evicted, oldest in buffer is 3) + events = bus.EventsSince("ws-1", 2) + if events != nil { + t.Fatalf("expected nil (gap) for sinceID=2, got %d events", len(events)) + } + + // sinceID=1 should also be a gap + events = bus.EventsSince("ws-1", 1) + if events != nil { + t.Fatalf("expected nil (gap) for sinceID=1, got %d events", len(events)) + } +} diff --git a/internal/events/redis_bus.go b/internal/events/redis_bus.go index 09ece4cc..149f8af8 100644 --- a/internal/events/redis_bus.go +++ b/internal/events/redis_bus.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" "sync" + "sync/atomic" "time" "github.com/redis/go-redis/v9" @@ -14,6 +15,11 @@ const ( // redisChannelPrefix is prepended to workspace IDs for Redis pub/sub channels. redisChannelPrefix = "pad:events:" + // redisSeqKey is the Redis key used for the global event sequence counter. + // All instances share this counter so SSE event IDs are globally ordered + // and Last-Event-ID is valid across any instance on reconnect. + redisSeqKey = "pad:event_seq" + // reconnectDelay is how long to wait before retrying a failed Redis subscription. reconnectDelay = 2 * time.Second ) @@ -32,6 +38,15 @@ type RedisBus struct { wsCounts map[string]int // workspace → local subscriber count wsSubs map[string]*redisSub // workspace → active Redis subscription + // Monotonic sequence counter for event IDs (local to this instance). + seq atomic.Int64 + + // Per-workspace replay buffers for Last-Event-ID support. + // Populated from events received via Redis pub/sub. + replayMu sync.RWMutex + replayBuffers map[string]*replayBuffer + replaySize int + ctx context.Context cancel context.CancelFunc } @@ -47,12 +62,14 @@ type redisSub struct { func NewRedisBus(client *redis.Client) *RedisBus { ctx, cancel := context.WithCancel(context.Background()) return &RedisBus{ - client: client, - subscribers: make(map[chan Event]*subscriber), - wsCounts: make(map[string]int), - wsSubs: make(map[string]*redisSub), - ctx: ctx, - cancel: cancel, + client: client, + subscribers: make(map[chan Event]*subscriber), + wsCounts: make(map[string]int), + wsSubs: make(map[string]*redisSub), + replayBuffers: make(map[string]*replayBuffer), + replaySize: DefaultReplayBufferSize, + ctx: ctx, + cancel: cancel, } } @@ -129,11 +146,24 @@ func (b *RedisBus) Unsubscribe(ch chan Event) { } // Publish sends an event to Redis, which distributes it to all instances. +// Events are assigned a globally unique sequence ID via Redis INCR so that +// Last-Event-ID is valid across any instance on reconnect. func (b *RedisBus) Publish(event Event) { if event.Timestamp == 0 { event.Timestamp = time.Now().UnixMilli() } + // Assign a globally ordered sequence ID via Redis atomic counter. + // This ensures all instances share the same ID space, so Last-Event-ID + // from one instance is meaningful on any other instance. + id, err := b.client.Incr(b.ctx, redisSeqKey).Result() + if err != nil { + // Fall back to local counter if Redis INCR fails (degraded mode). + slog.Warn("failed to get global event ID from Redis, falling back to local", "error", err) + id = b.seq.Add(1) + } + event.ID = id + data, err := json.Marshal(event) if err != nil { slog.Error("failed to marshal event for Redis", "error", err) @@ -146,6 +176,19 @@ func (b *RedisBus) Publish(event Event) { } } +// EventsSince returns buffered events for a workspace with IDs greater than sinceID. +// Returns nil if sinceID has been evicted from the buffer (gap too large). +func (b *RedisBus) EventsSince(workspaceID string, sinceID int64) []Event { + b.replayMu.RLock() + defer b.replayMu.RUnlock() + + rb, ok := b.replayBuffers[workspaceID] + if !ok { + return []Event{} + } + return rb.since(sinceID) +} + // Close shuts down all Redis subscriptions and closes local subscriber channels. func (b *RedisBus) Close() { b.cancel() // signal all subscription goroutines to stop @@ -227,8 +270,23 @@ func (b *RedisBus) receiveMessages(ctx context.Context, pubsub *redis.PubSub, wo } } -// fanOutLocally distributes an event to all local subscribers for the event's workspace. +// fanOutLocally distributes an event to all local subscribers for the event's workspace +// and stores it in the replay buffer. func (b *RedisBus) fanOutLocally(event Event) { + // Events received via Redis pub/sub already carry a global ID assigned by + // the publishing instance via Redis INCR. We use that ID directly so all + // instances share the same ID space for Last-Event-ID replay. + + // Store in replay buffer for reconnect replay. + b.replayMu.Lock() + rb, ok := b.replayBuffers[event.WorkspaceID] + if !ok { + rb = newReplayBuffer(b.replaySize) + b.replayBuffers[event.WorkspaceID] = rb + } + rb.append(event) + b.replayMu.Unlock() + b.mu.RLock() defer b.mu.RUnlock() diff --git a/internal/metrics/instrumented_bus.go b/internal/metrics/instrumented_bus.go index 3c37cc55..c3ceb6a1 100644 --- a/internal/metrics/instrumented_bus.go +++ b/internal/metrics/instrumented_bus.go @@ -94,3 +94,8 @@ func (b *InstrumentedBus) SubscriberCount() int { func (b *InstrumentedBus) WorkspaceSubscriberCount(workspaceID string) int { return b.inner.WorkspaceSubscriberCount(workspaceID) } + +// EventsSince delegates to the inner bus. +func (b *InstrumentedBus) EventsSince(workspaceID string, sinceID int64) []events.Event { + return b.inner.EventsSince(workspaceID, sinceID) +} diff --git a/internal/server/handlers_changes.go b/internal/server/handlers_changes.go new file mode 100644 index 00000000..531e9e8b --- /dev/null +++ b/internal/server/handlers_changes.go @@ -0,0 +1,77 @@ +package server + +import ( + "net/http" + "strconv" + "time" +) + +// ChangesResponse is the response for GET /workspaces/{ws}/changes?since=. +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= +// +// 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) +} diff --git a/internal/server/handlers_events.go b/internal/server/handlers_events.go index 681ac14c..91be62fe 100644 --- a/internal/server/handlers_events.go +++ b/internal/server/handlers_events.go @@ -5,11 +5,18 @@ import ( "fmt" "log/slog" "net/http" + "strconv" "time" ) // handleSSE streams Server-Sent Events for a workspace. // GET /api/v1/events?workspace= +// +// Supports Last-Event-ID: when a client reconnects with a Last-Event-ID header +// (set automatically by the browser's EventSource), the server replays any +// missed events from its in-memory replay buffer before entering the live +// stream. If the requested ID is too old (evicted from the buffer), the server +// sends a "sync_required" event so the client knows to do a full refresh. func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { // SSE requires the event bus if s.events == nil { @@ -69,12 +76,38 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { } // Send initial connected event - writeSSEEvent(w, "connected", map[string]string{ + writeSSEEvent(w, "connected", 0, map[string]string{ "workspace_id": ws.ID, "workspace": ws.Slug, }) flusher.Flush() + // Replay missed events if the client provided Last-Event-ID. + // The browser's EventSource sends this automatically on reconnect. + if lastIDStr := r.Header.Get("Last-Event-ID"); lastIDStr != "" { + lastID, parseErr := strconv.ParseInt(lastIDStr, 10, 64) + if parseErr == nil && lastID > 0 { + missed := s.events.EventsSince(ws.ID, lastID) + if missed == nil { + // Gap too large — buffer evicted. Tell client to do a full sync. + slog.Info("SSE replay gap too large, sending sync_required", + "workspace", ws.Slug, "last_event_id", lastID) + writeSSEEvent(w, "sync_required", 0, map[string]string{ + "reason": "Event buffer exceeded. Full sync required.", + }) + flusher.Flush() + } else if len(missed) > 0 { + slog.Info("SSE replaying missed events", + "workspace", ws.Slug, "last_event_id", lastID, "count", len(missed)) + for _, event := range missed { + writeSSEEvent(w, event.Type, event.ID, event) + flusher.Flush() + } + } + // If len(missed) == 0: client is caught up, nothing to replay. + } + } + // Keepalive ticker keepalive := time.NewTicker(30 * time.Second) defer keepalive.Stop() @@ -91,7 +124,7 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { // Channel closed (unsubscribed) return } - writeSSEEvent(w, event.Type, event) + writeSSEEvent(w, event.Type, event.ID, event) flusher.Flush() case <-keepalive.C: @@ -103,11 +136,16 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { } // writeSSEEvent writes a single SSE event to the response writer. -func writeSSEEvent(w http.ResponseWriter, eventType string, data interface{}) { +// If eventID > 0, an "id:" field is included for Last-Event-ID support. +func writeSSEEvent(w http.ResponseWriter, eventType string, eventID int64, data interface{}) { jsonData, err := json.Marshal(data) if err != nil { slog.Error("failed to marshal SSE event", "error", err) return } - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, jsonData) + if eventID > 0 { + fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", eventID, eventType, jsonData) + } else { + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, jsonData) + } } diff --git a/internal/server/server.go b/internal/server/server.go index 4e6a549e..80d42b79 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -415,6 +415,9 @@ func (s *Server) setupRouter() { // Dashboard (v2) r.Get("/dashboard", s.handleGetDashboard) + + // Incremental sync — returns items changed since a timestamp + r.Get("/changes", s.handleGetChanges) }) }) diff --git a/internal/store/items.go b/internal/store/items.go index 50741d59..488da918 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -1605,6 +1605,70 @@ func scanItems(rows *sql.Rows) ([]models.Item, error) { return items, rows.Err() } +// ItemsModifiedSince returns items in a workspace that were updated after the +// given timestamp. Used for incremental sync on tab resume. Also returns IDs of +// items that were deleted (hard-deleted or archived) since the timestamp. +// +// The updated list includes both active AND recently archived items (those with +// deleted_at > since). This lets the frontend update archived views correctly — +// an item that was just archived needs its full data to appear in archived views, +// not just its ID in the deleted list. +func (s *Store) ItemsModifiedSince(workspaceID string, since time.Time) (updated []models.Item, deletedIDs []string, err error) { + sinceStr := since.UTC().Format(time.RFC3339) + + // Fetch updated items: active items modified since the timestamp, + // PLUS items archived since the timestamp (so archived views can update). + query := s.q(` + SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, + i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, + i.created_by, i.last_modified_by, i.source, + i.item_number, i.created_at, i.updated_at, + c.slug, c.name, c.icon, c.prefix, + COALESCE(au.name, ''), COALESCE(au.email, ''), + COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, '') + FROM items i + JOIN collections c ON c.id = i.collection_id + LEFT JOIN users au ON au.id = i.assigned_user_id + LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id + WHERE i.workspace_id = ? + AND i.updated_at > ? + AND (i.deleted_at IS NULL OR i.deleted_at > ?) + ORDER BY i.updated_at ASC + `) + + rows, err := s.db.Query(query, workspaceID, sinceStr, sinceStr) + if err != nil { + return nil, nil, err + } + defer rows.Close() + updated, err = scanItems(rows) + if err != nil { + return nil, nil, err + } + + // Fetch IDs of items deleted since the timestamp. + delQuery := s.q(` + SELECT id FROM items + WHERE workspace_id = ? + AND deleted_at IS NOT NULL + AND deleted_at > ? + `) + delRows, err := s.db.Query(delQuery, workspaceID, sinceStr) + if err != nil { + return updated, nil, err + } + defer delRows.Close() + for delRows.Next() { + var id string + if err := delRows.Scan(&id); err != nil { + return updated, nil, err + } + deletedIDs = append(deletedIDs, id) + } + + return updated, deletedIDs, delRows.Err() +} + func hydrateItemComputedMetadata(item *models.Item) { if item == nil { return diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 26145d8d..611f17e5 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -32,7 +32,8 @@ import type { AgentRole, AgentRoleCreate, AgentRoleUpdate, - RoleBoardLane + RoleBoardLane, + ChangesResponse } from '$lib/types'; const BASE = '/api/v1'; @@ -402,6 +403,14 @@ export const api = { request(`/workspaces/${ws}/dashboard`) }, + // ── Incremental Sync ───────────────────────────────────────────────────── + + changes: { + /** Fetch items modified since the given timestamp (unix ms). */ + since: (ws: string, sinceMs: number) => + request(`/workspaces/${ws}/changes?since=${sinceMs}`) + }, + // ── Search ──────────────────────────────────────────────────────────────── search: (query: string, workspace?: string) => { diff --git a/web/src/lib/components/ChildItems.svelte b/web/src/lib/components/ChildItems.svelte index 09187a48..d565eb39 100644 --- a/web/src/lib/components/ChildItems.svelte +++ b/web/src/lib/components/ChildItems.svelte @@ -3,7 +3,7 @@ import { onDestroy, onMount } from 'svelte'; import { api } from '$lib/api/client'; import { sseService } from '$lib/services/sse.svelte'; - import { visibility } from '$lib/services/visibility.svelte'; + import { syncService } from '$lib/services/sync.svelte'; import type { Item } from '$lib/types'; import { parseFields, formatItemRef } from '$lib/types'; import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'; @@ -29,7 +29,7 @@ let loading = $state(true); let error = $state(''); let unsubscribeSSE: (() => void) | null = null; - let unsubscribeVisibility: (() => void) | null = null; + let unsubscribeSync: (() => void) | null = null; let expandedIds = $state>(new Set()); @@ -138,9 +138,12 @@ }); onMount(() => { - unsubscribeVisibility = visibility.onTabResume(() => { + unsubscribeSync = syncService.onSync((result) => { if (!wsSlug || !itemSlug) return; - loadChildren(); + // Only reload children on actual changes, not when caught up + if (result.type !== 'caught_up') { + loadChildren(); + } }); }); @@ -158,7 +161,7 @@ onDestroy(() => { unsubscribeSSE?.(); - unsubscribeVisibility?.(); + unsubscribeSync?.(); }); function formatLabel(value: string): string { diff --git a/web/src/lib/services/sse.svelte.ts b/web/src/lib/services/sse.svelte.ts index 1b14189f..32e9c9b6 100644 --- a/web/src/lib/services/sse.svelte.ts +++ b/web/src/lib/services/sse.svelte.ts @@ -4,6 +4,7 @@ export type SSEStatus = 'disconnected' | 'connected' | 'reconnecting'; export interface ItemEvent { type: string; + id?: number; workspace_id: string; item_id: string; title: string; @@ -31,14 +32,29 @@ const ITEM_EVENTS = [ function createSSEService() { let status = $state('disconnected'); let lastEventTime = $state(0); + let needsSync = $state(false); let eventSource: EventSource | null = null; + let currentWorkspace: string = ''; const callbacks = new SvelteSet(); function connect(workspaceSlug: string) { + // If already connected to the same workspace, don't reconnect. + // The browser's EventSource handles reconnection automatically + // with Last-Event-ID, so destroying it would lose that state. + if (eventSource && currentWorkspace === workspaceSlug) { + // EventSource is already connected (or auto-reconnecting). + // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSED + if (eventSource.readyState !== EventSource.CLOSED) { + return; + } + } + + // Different workspace or closed connection — create new EventSource if (eventSource) { disconnect(); } + currentWorkspace = workspaceSlug; const url = `/api/v1/events?workspace=${encodeURIComponent(workspaceSlug)}`; eventSource = new EventSource(url); @@ -48,12 +64,25 @@ function createSSEService() { eventSource.onerror = () => { status = 'reconnecting'; + // EventSource auto-reconnects and sends Last-Event-ID. + // The server replays missed events from its buffer. }; eventSource.addEventListener('connected', () => { status = 'connected'; }); + // Handle sync_required: server's replay buffer couldn't cover the gap. + // Trigger an immediate sync rather than waiting for a visibility change, + // so the UI stays fresh even when the tab is actively visible. + eventSource.addEventListener('sync_required', () => { + needsSync = true; + // Dynamic import to avoid circular dependency + import('./sync.svelte').then(({ syncService }) => { + syncService.triggerSync(); + }); + }); + for (const eventType of ITEM_EVENTS) { eventSource.addEventListener(eventType, (e: MessageEvent) => { const data: ItemEvent = JSON.parse(e.data); @@ -70,9 +99,19 @@ function createSSEService() { eventSource.close(); eventSource = null; } + currentWorkspace = ''; status = 'disconnected'; } + /** Force reconnect (e.g., after auth change). */ + function reconnect() { + const ws = currentWorkspace; + if (ws) { + disconnect(); + connect(ws); + } + } + function onItemEvent(callback: ItemEventCallback): () => void { callbacks.add(callback); return () => { @@ -80,6 +119,10 @@ function createSSEService() { }; } + function clearSyncFlag() { + needsSync = false; + } + return { get status() { return status; @@ -87,9 +130,14 @@ function createSSEService() { get lastEventTime() { return lastEventTime; }, + get needsSync() { + return needsSync; + }, connect, disconnect, - onItemEvent + reconnect, + onItemEvent, + clearSyncFlag }; } diff --git a/web/src/lib/services/sync.svelte.ts b/web/src/lib/services/sync.svelte.ts new file mode 100644 index 00000000..2ea2b539 --- /dev/null +++ b/web/src/lib/services/sync.svelte.ts @@ -0,0 +1,214 @@ +/** + * Sync coordinator — centralizes tab-resume data synchronization. + * + * Instead of every page/component independently refetching everything on + * visibilitychange, this service: + * + * 1. Tracks the last successful sync timestamp + * 2. On tab resume, checks SSE health first + * 3. If SSE replayed missed events: no action needed (already caught up) + * 4. If SSE signals sync_required: uses the /changes endpoint for a delta sync + * 5. Notifies registered page-level callbacks with the sync result + * 6. Only does a full refetch as a last resort (long absence, errors) + * + * Pages register lightweight callbacks that receive the sync result and can + * update their local state accordingly — no more blind full refetches. + */ + +import { api } from '$lib/api/client'; +import { sseService } from '$lib/services/sse.svelte'; +import type { Item, ChangesResponse } from '$lib/types'; + +export type SyncResult = { + type: 'caught_up'; // SSE was healthy, nothing missed +} | { + type: 'incremental'; // Delta sync via /changes + changes: ChangesResponse; +} | { + type: 'full_refresh'; // Gap too large or error — caller should reload everything +}; + +type SyncCallback = (result: SyncResult) => void; + +/** + * How long the tab must have been hidden before we bother syncing at all. + * Short absences (< 2s) almost certainly had no changes. + */ +const MIN_ABSENCE_MS = 2000; + +/** + * If the tab has been hidden longer than this, skip incremental sync + * and go straight to full refresh. The /changes endpoint may return + * too much data for very long absences. + */ +const MAX_INCREMENTAL_MS = 10 * 60 * 1000; // 10 minutes + +function createSyncService() { + let lastSyncTime = $state(Date.now()); + let hiddenSince = $state(0); + let syncing = $state(false); + let wsSlug = $state(''); + let initialized = false; + + const callbacks = new Set(); + + // Track when the tab was hidden/shown + function init() { + if (initialized || typeof document === 'undefined') return; + initialized = true; + + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + hiddenSince = Date.now(); + } else { + onTabResume(); + } + }); + } + + async function setWorkspace(slug: string) { + wsSlug = slug; + // Seed the sync cursor from the server's clock, not the client's. + // This avoids clock-skew issues where Date.now() on the client + // is ahead/behind the server, causing missed or duplicate changes. + try { + const changes = await api.changes.since(slug, Date.now()); + lastSyncTime = changes.server_time; + } catch { + // Fallback to client time if the server call fails. + // Not ideal, but better than leaving the cursor at 0. + lastSyncTime = Date.now(); + } + } + + /** Called when the tab becomes visible again. */ + async function onTabResume() { + if (syncing || !wsSlug) return; + + const absence = hiddenSince > 0 ? Date.now() - hiddenSince : 0; + hiddenSince = 0; + + // Very short absence — SSE almost certainly kept up, skip sync + if (absence < MIN_ABSENCE_MS) return; + + syncing = true; + try { + const result = await determineSync(absence); + // Only advance the cursor for incremental syncs (we know exactly + // what the server returned). For full_refresh, DON'T advance here — + // the cursor stays put until a page callback successfully reloads + // and calls markSynced(). This prevents data loss if the reload fails. + if (result.type === 'incremental') { + lastSyncTime = result.changes.server_time; + } + // For 'caught_up': cursor stays as-is (nothing was missed). + // For 'full_refresh': cursor stays as-is until markSynced() is called. + notify(result); + } catch { + // On error, tell pages to do a full refresh as a safe fallback. + // Don't advance cursor — retry on next tab resume. + notify({ type: 'full_refresh' }); + } finally { + syncing = false; + } + } + + async function determineSync(absenceMs: number): Promise { + // If SSE says it needs a full sync (buffer overflow), respect that + if (sseService.needsSync) { + sseService.clearSyncFlag(); + return doIncrementalOrFull(absenceMs); + } + + // If SSE is connected and the absence was short enough that the + // replay buffer should have covered it, we're caught up. + if (sseService.status === 'connected' && absenceMs < MAX_INCREMENTAL_MS) { + // SSE EventSource auto-reconnects with Last-Event-ID. + // If the server replayed events, the SSE callbacks already + // updated the store. Check if SSE received events recently. + const timeSinceLastEvent = Date.now() - sseService.lastEventTime; + + // If SSE got events recently (within the absence window), it + // likely replayed everything we missed. + if (sseService.lastEventTime > 0 && timeSinceLastEvent < absenceMs + 5000) { + return { type: 'caught_up' }; + } + } + + return doIncrementalOrFull(absenceMs); + } + + async function doIncrementalOrFull(absenceMs: number): Promise { + // Very long absence — skip incremental, do full refresh + if (absenceMs > MAX_INCREMENTAL_MS) { + return { type: 'full_refresh' }; + } + + // Try incremental sync via /changes endpoint + try { + const changes = await api.changes.since(wsSlug, lastSyncTime); + if (changes.updated.length === 0 && changes.deleted.length === 0) { + return { type: 'caught_up' }; + } + return { type: 'incremental', changes }; + } catch { + // /changes failed — fall back to full refresh + return { type: 'full_refresh' }; + } + } + + function onSync(cb: SyncCallback): () => void { + callbacks.add(cb); + return () => { callbacks.delete(cb); }; + } + + function notify(result: SyncResult) { + for (const cb of callbacks) { + try { + cb(result); + } catch { + // Don't let one failing callback break others + } + } + } + + /** Mark a successful data load (updates the sync timestamp). */ + function markSynced() { + lastSyncTime = Date.now(); + } + + /** + * Trigger a sync immediately (e.g., when SSE sends sync_required + * while the tab is still visible). This bypasses the visibility + * change listener and runs the sync directly. + */ + async function triggerSync() { + if (syncing || !wsSlug) return; + syncing = true; + try { + // SSE told us there's a gap — try incremental, fall back to full + const result = await doIncrementalOrFull(MAX_INCREMENTAL_MS); + if (result.type === 'incremental') { + lastSyncTime = result.changes.server_time; + } + // For full_refresh: don't advance cursor until pages confirm success. + notify(result); + } catch { + notify({ type: 'full_refresh' }); + } finally { + syncing = false; + } + } + + return { + get syncing() { return syncing; }, + get lastSyncTime() { return lastSyncTime; }, + init, + setWorkspace, + onSync, + markSynced, + triggerSync + }; +} + +export const syncService = createSyncService(); diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index a6ab660d..ce92e001 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -547,6 +547,15 @@ export interface DashboardResponse { }[]; } +// ─── Incremental Sync ──────────────────────────────────────────────────────── + +export interface ChangesResponse { + updated: Item[]; + deleted: string[]; + server_time: number; + collections_changed: boolean; +} + // ─── Search ────────────────────────────────────────────────────────────────── export interface SearchResult { diff --git a/web/src/routes/[workspace]/+layout.svelte b/web/src/routes/[workspace]/+layout.svelte index d7cda163..3ce0b31c 100644 --- a/web/src/routes/[workspace]/+layout.svelte +++ b/web/src/routes/[workspace]/+layout.svelte @@ -5,7 +5,7 @@ import { collectionStore } from '$lib/stores/collections.svelte'; import { editorStore } from '$lib/stores/editor.svelte'; import { sseService } from '$lib/services/sse.svelte'; - import { visibility } from '$lib/services/visibility.svelte'; + import { syncService } from '$lib/services/sync.svelte'; import { api } from '$lib/api/client'; import { toastStore } from '$lib/stores/toast.svelte'; @@ -13,23 +13,26 @@ let wsSlug = $derived(page.params.workspace ?? ''); let unsubscribeSSE: (() => void) | null = null; - let unsubscribeVisibility: (() => void) | null = null; + let unsubscribeSync: (() => void) | null = null; onMount(() => { - visibility.init(); - unsubscribeVisibility = visibility.onTabResume(() => { + // Initialize the sync coordinator (sets up visibilitychange listener once) + syncService.init(); + + // Listen for sync results to refresh collection metadata + unsubscribeSync = syncService.onSync((result) => { if (!wsSlug) return; - // Reconnect SSE — events may have been lost while the tab was hidden - sseService.connect(wsSlug); - // Refresh collection metadata (counts, etc.) - collectionStore.loadCollections(wsSlug); + if (result.type === 'full_refresh' || (result.type === 'incremental' && result.changes.collections_changed)) { + collectionStore.loadCollections(wsSlug); + } }); + connectSSE(); }); onDestroy(() => { unsubscribeSSE?.(); - unsubscribeVisibility?.(); + unsubscribeSync?.(); sseService.disconnect(); }); @@ -38,6 +41,7 @@ if (wsSlug) { workspaceStore.setCurrent(wsSlug); collectionStore.loadCollections(wsSlug); + syncService.setWorkspace(wsSlug); connectSSE(); } }); diff --git a/web/src/routes/[workspace]/+page.svelte b/web/src/routes/[workspace]/+page.svelte index 7d0fbde0..d9635cda 100644 --- a/web/src/routes/[workspace]/+page.svelte +++ b/web/src/routes/[workspace]/+page.svelte @@ -5,7 +5,7 @@ import { browser } from '$app/environment'; import { api } from '$lib/api/client'; import { workspaceStore } from '$lib/stores/workspace.svelte'; - import { visibility } from '$lib/services/visibility.svelte'; + import { syncService } from '$lib/services/sync.svelte'; import { relativeTime } from '$lib/utils/markdown'; import { itemUrlId } from '$lib/types'; import OnboardingChecklist from '$lib/components/OnboardingChecklist.svelte'; @@ -40,20 +40,24 @@ if (wsSlug) load(wsSlug); }); - let unsubscribeVisibility: (() => void) | null = null; + let unsubscribeSync: (() => void) | null = null; onMount(() => { pollTimer = setInterval(() => { if (wsSlug) load(wsSlug, true); }, 30000); - unsubscribeVisibility = visibility.onTabResume(() => { - if (wsSlug) load(wsSlug, true); + // Dashboard always does a full reload on any sync signal since it's + // an aggregated view (counts, activity, suggestions change with any item update) + unsubscribeSync = syncService.onSync((result) => { + if (result.type !== 'caught_up' && wsSlug) { + load(wsSlug, true); + } }); return () => clearInterval(pollTimer); }); onDestroy(() => { - unsubscribeVisibility?.(); + unsubscribeSync?.(); }); async function load(slug: string, silent = false) { diff --git a/web/src/routes/[workspace]/[collection]/+page.svelte b/web/src/routes/[workspace]/[collection]/+page.svelte index 377c7155..9ec375d5 100644 --- a/web/src/routes/[workspace]/[collection]/+page.svelte +++ b/web/src/routes/[workspace]/[collection]/+page.svelte @@ -11,7 +11,7 @@ import QuickActionsMenu from '$lib/components/common/QuickActionsMenu.svelte'; import { onDestroy, onMount } from 'svelte'; import { sseService } from '$lib/services/sse.svelte'; - import { visibility } from '$lib/services/visibility.svelte'; + import { syncService } from '$lib/services/sync.svelte'; import { toastStore } from '$lib/stores/toast.svelte'; type ViewMode = 'list' | 'board' | 'table'; @@ -129,36 +129,60 @@ }); }); - // Silently refresh items when the tab regains focus (SSE events may have been lost) - let unsubscribeVisibility: (() => void) | null = null; + // Sync coordinator — handle tab-resume data refresh efficiently + let unsubscribeSync: (() => void) | null = null; onMount(() => { - unsubscribeVisibility = visibility.onTabResume(async () => { + unsubscribeSync = syncService.onSync(async (result) => { if (!wsSlug || !collSlug) return; + + if (result.type === 'caught_up') return; + + if (result.type === 'incremental') { + // Apply incremental changes to this collection's item list + const changes = result.changes; + let changed = false; + + for (const updated of changes.updated) { + const existingIdx = items.findIndex(i => i.id === updated.id); + + if (updated.collection_slug === collSlug) { + // Item belongs to this collection — update or add + changed = true; + if (existingIdx >= 0) { + items[existingIdx] = updated; + } else { + items = [...items, updated]; + } + } else if (existingIdx >= 0) { + // Item was moved OUT of this collection — remove it + changed = true; + items = items.filter(i => i.id !== updated.id); + } + } + + for (const deletedId of changes.deleted) { + const idx = items.findIndex(i => i.id === deletedId); + if (idx >= 0) { + changed = true; + items = items.filter(i => i.id !== deletedId); + } + } + + // Refresh progress data if anything changed + if (changed) { + await refreshProgress(wsSlug, collSlug, items); + } + return; + } + + // Full refresh fallback try { const listParams = showArchived ? { include_archived: true } : undefined; const freshItems = await api.items.listByCollection(wsSlug, collSlug, listParams); items = freshItems; - - // Update progress data without resetting view state - if (collSlug === 'plans') { - const progress = await api.items.plansProgress(wsSlug).catch(() => []); - const map: Record = {}; - for (const p of progress) { - map[p.item_id] = { total: p.total, done: p.done }; - } - itemProgress = map; - } else { - const map: Record = {}; - for (const it of freshItems) { - if (!it.content) continue; - const total = (it.content.match(/- \[[ x]\]/g) ?? []).length; - if (total === 0) continue; - const done = (it.content.match(/- \[x\]/g) ?? []).length; - map[it.id] = { total, done }; - } - itemProgress = map; - } + await refreshProgress(wsSlug, collSlug, freshItems); + syncService.markSynced(); // Advance cursor now that reload succeeded } catch { // Ignore — will catch up on next SSE event } @@ -167,9 +191,30 @@ onDestroy(() => { unsubscribeSSE?.(); - unsubscribeVisibility?.(); + unsubscribeSync?.(); }); + async function refreshProgress(ws: string, coll: string, itemList: typeof items) { + if (coll === 'plans') { + const progress = await api.items.plansProgress(ws).catch(() => []); + const map: Record = {}; + for (const p of progress) { + map[p.item_id] = { total: p.total, done: p.done }; + } + itemProgress = map; + } else { + const map: Record = {}; + for (const it of itemList) { + if (!it.content) continue; + const total = (it.content.match(/- \[[ x]\]/g) ?? []).length; + if (total === 0) continue; + const done = (it.content.match(/- \[x\]/g) ?? []).length; + map[it.id] = { total, done }; + } + itemProgress = map; + } + } + async function loadCollection(ws: string, coll: string, includeArchived = false) { loading = true; try { diff --git a/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte b/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte index ece63a0e..d803e662 100644 --- a/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte +++ b/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte @@ -3,7 +3,7 @@ import { tick, onMount, onDestroy } from 'svelte'; import { api } from '$lib/api/client'; import { collectionStore } from '$lib/stores/collections.svelte'; - import { visibility } from '$lib/services/visibility.svelte'; + import { syncService } from '$lib/services/sync.svelte'; import Editor from '$lib/components/editor/Editor.svelte'; import EditorBubbleMenu from '$lib/components/editor/EditorBubbleMenu.svelte'; import EditorLinkPopover from '$lib/components/editor/EditorLinkPopover.svelte'; @@ -87,24 +87,45 @@ } }); - // Refresh item when the tab regains focus (SSE events may have been lost) - let unsubscribeVisibility: (() => void) | null = null; + // Sync coordinator — refresh item data on tab resume + let unsubscribeSync: (() => void) | null = null; onMount(() => { - unsubscribeVisibility = visibility.onTabResume(async () => { + unsubscribeSync = syncService.onSync(async (result) => { if (!wsSlug || !itemSlug || !item) return; // Don't refresh if the user is actively editing if (saveStatus === 'saving' || editingTitle) return; + + if (result.type === 'caught_up') return; + + if (result.type === 'incremental') { + // Check if our item is in the changed set + const updated = result.changes.updated.find(i => i.id === item!.id); + if (updated) { + // Merge server state without disrupting the editor + item = { + ...updated, + content: item!.content + }; + itemLinks = await api.links.list(wsSlug, updated.slug).catch(() => []); + } + // Check if our item was deleted + if (result.changes.deleted.includes(item!.id)) { + // Item was deleted — navigate back to collection + goto(`/${wsSlug}/${collSlug}`); + } + return; + } + + // Full refresh fallback try { const updated = await api.items.get(wsSlug, itemSlug); - // Merge server state without disrupting the editor: - // update fields/metadata but preserve local content to avoid resetting the editor item = { ...updated, content: item!.content }; - // Refresh links too itemLinks = await api.links.list(wsSlug, updated.slug).catch(() => []); + syncService.markSynced(); // Advance cursor now that reload succeeded } catch { // Ignore — will catch up on next event } @@ -112,7 +133,7 @@ }); onDestroy(() => { - unsubscribeVisibility?.(); + unsubscribeSync?.(); }); async function loadData() {