feat(notes): push, long polling and fallback detection for note sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
Phase 4 of the collaboration plan:
- The sync engine listens for DOC_SEQ in the note's realtime room and syncs
right away. Without WebSocket/SSE it long-polls /docs/{id}/changes (25 s);
after three failures it falls back to polling every 3 s and retries long
polling when the browser comes back online.
- With push or long polling the engine only syncs every 7 s while holding
leases (renewal), otherwise every 30 s as a safety net. Focusing a block
announces its lease immediately (otherwise it could take up to 30 s).
- Realtime connection: SSE counts as connected only after its first event;
a stream that stays silent for 5 s (buffering proxy) switches SSE off for
the tab. App-level PING/PONG detects WebSockets that are accepted but
forward nothing and switches to SSE after 35 s.
- The editor shows the active channel for shared notes.
- Tests: engine channel tests (push, heartbeat, lease on focus, long
polling, interval fallback, switching), realtime fallback tests with fake
WebSocket/EventSource, Go tests for PONG and DOC_SEQ delivery, and
e2e/channels.e2e.mjs measuring latency behind six simulated proxy setups
(0.2-0.3 s on all live channels, ~3 s with plain polling).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa397c18cd
commit
8b7a53daab
18 files changed
+783
-37
No files matched your search
@@ -11,7 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/collab"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
@@ -266,3 +268,71 @@ func TestReorderRejectsForeignTasks(t *testing.T) {
|
||||
t.Fatalf("foreign task was modified: sortOrder=%d completed=%v", after.SortOrder, after.IsCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
func dialWS(t *testing.T, ts *httptest.Server, token string) *websocket.Conn {
|
||||
t.Helper()
|
||||
url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?token=" + token
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial ws: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
func readWSEvent(conn *websocket.Conn, event string, timeout time.Duration) *sseMsg {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
_ = conn.SetReadDeadline(deadline)
|
||||
var m sseMsg
|
||||
if err := conn.ReadJSON(&m); err != nil {
|
||||
return nil
|
||||
}
|
||||
if m.Event == event {
|
||||
return &m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWebSocketHeartbeat(t *testing.T) {
|
||||
_, _, ts, _, token := startRealtimeServer(t)
|
||||
conn := dialWS(t, ts, token)
|
||||
if err := conn.WriteJSON(map[string]string{"event": "PING"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if readWSEvent(conn, "PONG", 2*time.Second) == nil {
|
||||
t.Fatal("no PONG for an app-level PING")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocChangesArePushedToTheNoteRoom(t *testing.T) {
|
||||
_, store, ts, owner, ownerToken := startRealtimeServer(t)
|
||||
editor, editorToken := newTestUser(t, store, "push-editor@example.com")
|
||||
doc, err := store.CreateDocument(owner.ID, "", "Push", "📄", "", []domain.DocumentBlock{{ID: "push_b1", Type: "bullet", Content: "Start"}}, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.CreateDocShare(owner.ID, doc.ID, editor.ID, "write"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
conn := dialWS(t, ts, editorToken)
|
||||
if err := conn.WriteJSON(map[string]string{"event": "JOIN_ROOM", "room": "doc:" + doc.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond) // the join is processed asynchronously
|
||||
|
||||
text := "Geändert"
|
||||
resp := doJSON(t, ts, http.MethodPost, "/api/v1/docs/"+doc.ID+"/sync", ownerToken, map[string]interface{}{
|
||||
"clientId": "owner-tab",
|
||||
"ops": []collab.Op{{OpID: "push-1", Kind: collab.OpSetContent, BlockID: "push_b1", BaseVersion: 1, Content: &text}},
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: %d", resp.StatusCode)
|
||||
}
|
||||
msg := readWSEvent(conn, "DOC_SEQ", 2*time.Second)
|
||||
if msg == nil || !strings.Contains(string(msg.Payload), doc.ID) {
|
||||
t.Fatalf("no DOC_SEQ push for the note: %+v", msg)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -631,7 +631,10 @@ func (c *Client) readPump() {
|
||||
}
|
||||
|
||||
// Room subscription without requiring docID
|
||||
if incoming.Event == "JOIN_ROOM" {
|
||||
if incoming.Event == "PING" {
|
||||
// App-level heartbeat: lets clients detect proxies that accept the upgrade but forward nothing
|
||||
c.SendEvent("PONG", nil)
|
||||
} else if incoming.Event == "JOIN_ROOM" {
|
||||
room := incoming.Room
|
||||
if room == "" {
|
||||
room = docID
|
||||
|
||||
Reference in new issue
Block a user