5 files changed,
+827,
-6
+150,
-0
1@@ -229,6 +229,156 @@ doc2.WithWriteTransaction(func(txn *ygo.Transaction) error {
2 })
3 ```
4
5+## Real-Time Sync with y-sweet Server
6+
7+For production collaborative editing, connect to a y-sweet server with explicit lifecycle management:
8+
9+```go
10+// Create document
11+doc, err := ygo.NewDoc()
12+if err != nil {
13+ log.Fatal(err)
14+}
15+defer doc.Destroy()
16+
17+// Create sync client (does not connect yet)
18+client, err := ygo.NewSyncClient(doc,
19+ ygo.WithSyncEndpoint("wss://y-sweet.example.com/doc/my-doc"),
20+ ygo.WithSyncAuthToken("my-token"),
21+)
22+if err != nil {
23+ log.Fatal(err)
24+}
25+
26+// Set up event handlers
27+client.OnConnect(func(doc *ygo.Doc, stats ygo.SyncStats) error {
28+ log.Printf("Connected with %d peers", stats.PeerCount)
29+ return nil
30+})
31+
32+client.OnDisconnect(func(doc *ygo.Doc, stats ygo.SyncStats) error {
33+ log.Printf("Disconnected - %d peers remaining", stats.PeerCount)
34+ // Note: doc is NOT destroyed here - you may want to reconnect
35+ return nil
36+})
37+
38+client.OnUpdateCtx(func(ctx context.Context, doc *ygo.Doc, stats ygo.SyncStats) error {
39+ // React to changes from other clients
40+ log.Printf("Update received, pending: %v", stats.PendingUpdate)
41+ return processRemoteChanges(ctx, doc)
42+})
43+
44+// Connect in a goroutine with retry logic
45+var connectErr error
46+var wg sync.WaitGroup
47+wg.Add(1)
48+
49+go func() {
50+ defer wg.Done()
51+
52+ for {
53+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
54+ err := client.Connect(ctx)
55+ cancel()
56+
57+ if err == nil {
58+ // Connected successfully - wait for disconnect
59+ <-client.Done()
60+
61+ // Check if we should reconnect or shut down
62+ select {
63+ case <-shutdownChan:
64+ return // Shutting down, don't reconnect
65+ default:
66+ log.Println("Disconnected, will retry...")
67+ time.Sleep(5 * time.Second)
68+ continue
69+ }
70+ }
71+
72+ // Connection failed
73+ connectErr = err
74+ log.Printf("Connection failed: %v", err)
75+
76+ select {
77+ case <-shutdownChan:
78+ return // Shutting down
79+ case <-time.After(5 * time.Second):
80+ // Retry connection
81+ continue
82+ }
83+ }
84+}()
85+
86+// Monitor connection stats
87+go func() {
88+ ticker := time.NewTicker(30 * time.Second)
89+ defer ticker.Stop()
90+
91+ for {
92+ select {
93+ case <-ticker.C:
94+ stats := client.Stats()
95+ log.Printf("Status: %v, Peers: %d, Pending: %v",
96+ stats.Status, stats.PeerCount, stats.PendingUpdate)
97+ case <-shutdownChan:
98+ return
99+ }
100+ }
101+}()
102+
103+// Run for some time, then shutdown
104+select {
105+case <-time.After(1 * time.Hour):
106+case <-signalChan: // Handle SIGTERM/SIGINT
107+}
108+
109+// Graceful shutdown
110+close(shutdownChan)
111+
112+// Flush pending updates before closing
113+flushCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
114+defer cancel()
115+
116+if err := client.Flush(flushCtx); err != nil {
117+ log.Printf("Flush failed: %v", err)
118+}
119+
120+// Close the connection
121+if err := client.Close(); err != nil {
122+ log.Printf("Close error: %v", err)
123+}
124+
125+// Wait for connection goroutine to finish
126+wg.Wait()
127+
128+// Finally destroy the document when completely done
129+// (This is your responsibility - never automatic)
130+doc.Destroy()
131+```
132+
133+### Sync Client Events
134+
135+- **OnConnect(doc, stats)**: Called when successfully connected to server
136+- **OnDisconnect(doc, stats)**: Called when connection closes (reconnect possible)
137+- **OnUpdate(doc, stats)**: Called when document receives changes from remote peers
138+- **OnUpdateCtx(ctx, doc, stats)**: Context-aware variant for cancellation/deadlines
139+
140+### Connection Statistics
141+
142+The `Stats()` method returns:
143+- `PeerCount`: Number of other clients connected (via awareness protocol)
144+- `LastUpdate`: Timestamp of last received remote update
145+- `PendingUpdate`: True if local changes haven't been synced yet
146+- `Status`: Current connection state (Disconnected, Connecting, Connected, Disconnecting)
147+
148+### Important: Document Lifecycle
149+
150+The sync client **never** destroys the document automatically, even on errors or disconnect. You must explicitly call `doc.Destroy()` when completely done. This allows you to:
151+- Retry connections on network hiccups
152+- Switch servers without losing data
153+- Queue updates while offline and sync when reconnected
154+
155 ## Platform Support
156
157 Pre-built static libraries are included for:
+153,
-6
1@@ -32,6 +32,24 @@ type SyncConn struct {
2 done chan struct{}
3 closeErr error
4 onAwarenessUpdate func([]byte) error
5+
6+ // Stats tracking
7+ peerCount int
8+ lastUpdate time.Time
9+ pendingUpdate bool
10+ statsMu sync.RWMutex
11+
12+ // Event callbacks
13+ onConnect func()
14+ onDisconnect func()
15+}
16+
17+// Stats holds connection statistics for SyncConn
18+type Stats struct {
19+ PeerCount int
20+ LastUpdate time.Time
21+ PendingUpdate bool
22+ Status Status
23 }
24
25 const (
26@@ -48,12 +66,17 @@ func NewSyncConn(doc DocInterface, opts Options) *SyncConn {
27 onUpdate = opts.OnUpdate
28 }
29 return &SyncConn{
30- doc: doc,
31- opts: opts,
32- onUpdate: onUpdate,
33- status: StatusDisconnected,
34- done: make(chan struct{}),
35- retries: 0,
36+ doc: doc,
37+ opts: opts,
38+ onUpdate: onUpdate,
39+ onConnect: opts.OnConnect,
40+ onDisconnect: opts.OnDisconnect,
41+ status: StatusDisconnected,
42+ done: make(chan struct{}),
43+ retries: 0,
44+ lastUpdate: time.Time{},
45+ peerCount: 0,
46+ pendingUpdate: false,
47 }
48 }
49
50@@ -73,6 +96,11 @@ func (s *SyncConn) connect(ctx context.Context) error {
51 s.status = StatusConnected
52 s.retries = 0
53
54+ // Trigger OnConnect callback
55+ if s.onConnect != nil {
56+ s.onConnect()
57+ }
58+
59 return nil
60 }
61
62@@ -207,6 +235,11 @@ func (s *SyncConn) handleUpdate(payload []byte) error {
63 return err
64 }
65
66+ // Update last update time on success
67+ s.statsMu.Lock()
68+ s.lastUpdate = time.Now()
69+ s.statsMu.Unlock()
70+
71 if s.onUpdate != nil {
72 s.onUpdate(s.doc)
73 }
74@@ -215,6 +248,11 @@ func (s *SyncConn) handleUpdate(payload []byte) error {
75 }
76
77 func (s *SyncConn) handleAwareness(payload []byte) error {
78+ // Update peer count from awareness message
79+ s.statsMu.Lock()
80+ s.peerCount = parseAwarenessPeerCount(payload)
81+ s.statsMu.Unlock()
82+
83 if s.onAwarenessUpdate != nil {
84 return s.onAwarenessUpdate(payload)
85 }
86@@ -271,6 +309,11 @@ func (s *SyncConn) handleDisconnect(ctx context.Context) {
87 return
88 }
89
90+ // Trigger OnDisconnect callback before marking disconnected
91+ if s.onDisconnect != nil {
92+ s.onDisconnect()
93+ }
94+
95 s.status = StatusDisconnected
96 if s.conn != nil {
97 s.conn.Close(websocket.StatusNormalClosure, "")
98@@ -397,3 +440,107 @@ func (s *SyncConn) GetConn() *websocket.Conn {
99 func (s *SyncConn) SetAwarenessUpdate(fn func([]byte) error) {
100 s.onAwarenessUpdate = fn
101 }
102+
103+// GetStats returns current connection statistics.
104+func (s *SyncConn) GetStats() Stats {
105+ s.statsMu.RLock()
106+ defer s.statsMu.RUnlock()
107+
108+ return Stats{
109+ PeerCount: s.peerCount,
110+ LastUpdate: s.lastUpdate,
111+ PendingUpdate: s.pendingUpdate,
112+ Status: s.status,
113+ }
114+}
115+
116+// SetPendingUpdate marks whether there are local changes waiting to sync.
117+func (s *SyncConn) SetPendingUpdate(pending bool) {
118+ s.statsMu.Lock()
119+ s.pendingUpdate = pending
120+ s.statsMu.Unlock()
121+}
122+
123+// Flush performs a best-effort sync of pending updates to the server.
124+// It sends any pending local changes synchronously.
125+// The context can be used to set a timeout.
126+func (s *SyncConn) Flush(ctx context.Context) error {
127+ if s.status != StatusConnected || s.conn == nil {
128+ return fmt.Errorf("not connected")
129+ }
130+
131+ s.statsMu.RLock()
132+ hasPending := s.pendingUpdate
133+ s.statsMu.RUnlock()
134+
135+ if !hasPending {
136+ return nil // Nothing to flush
137+ }
138+
139+ // Get current state and compute diff
140+ var updateData []byte
141+ err := s.doc.WithReadTransaction(func(txn Transaction) error {
142+ sv := txn.GetStateVector()
143+ if sv == nil {
144+ return fmt.Errorf("failed to get state vector")
145+ }
146+ updateData = s.doc.GetStateDiff(sv)
147+ return nil
148+ })
149+ if err != nil {
150+ return fmt.Errorf("failed to get state diff: %w", err)
151+ }
152+
153+ if len(updateData) == 0 {
154+ return nil
155+ }
156+
157+ // Send update
158+ msg := encodeSyncMessage(Update, updateData)
159+ err = s.conn.Write(ctx, websocket.MessageBinary, msg)
160+ if err != nil {
161+ return fmt.Errorf("failed to send update: %w", err)
162+ }
163+
164+ // Clear pending flag on success
165+ s.SetPendingUpdate(false)
166+
167+ return nil
168+}
169+
170+// parseAwarenessPeerCount extracts the number of peers from an awareness message.
171+// Awareness messages contain alternating client IDs and their state objects.
172+// We count unique client IDs to get the peer count.
173+func parseAwarenessPeerCount(data []byte) int {
174+ if len(data) == 0 {
175+ return 0
176+ }
177+
178+ // Simple parser: count entries in the awareness message
179+ // Format is a series of [clientId, state] pairs
180+ // We'll decode the varint length prefix and count entries
181+ count := 0
182+ offset := 0
183+
184+ for offset < len(data) {
185+ // Read client ID (varint)
186+ _, n, err := readVarUint(data[offset:])
187+ if err != nil {
188+ break
189+ }
190+ offset += n
191+ count++
192+
193+ // Skip the state object (varint length prefix + data)
194+ if offset >= len(data) {
195+ break
196+ }
197+ stateLen, n, err := readVarUint(data[offset:])
198+ if err != nil {
199+ break
200+ }
201+ offset += n + int(stateLen)
202+ }
203+
204+ return count
205+}
+42,
-0
1@@ -129,6 +129,8 @@ type Options struct {
2 OnUpdate func(doc DocInterface) error
3 AwarenessTimeout time.Duration
4 AwarenessState []byte
5+ OnConnect func() // NEW: called when connected
6+ OnDisconnect func() // NEW: called when disconnected
7 }
8
9 // defaultOptions returns the default configuration options.
10@@ -181,6 +183,22 @@ func WithAwarenessState(state []byte) Option {
11 }
12 }
13
14+// WithOnConnect sets a callback function that is called when the client
15+// successfully connects to the server.
16+func WithOnConnect(fn func()) Option {
17+ return func(o *Options) {
18+ o.OnConnect = fn
19+ }
20+}
21+
22+// WithOnDisconnect sets a callback function that is called when the client
23+// disconnects from the server.
24+func WithOnDisconnect(fn func()) Option {
25+ return func(o *Options) {
26+ o.OnDisconnect = fn
27+ }
28+}
29+
30 // SendUpdate sends a document update to the server.
31 // It returns nil if not connected, so callers don't need to check status.
32 func (c *SyncClient) SendUpdate(update UpdateData) error {
33@@ -259,3 +277,27 @@ func (c *SyncClient) SendPendingAndClose() error {
34 }
35 return c.syncConn.SendPendingAndClose()
36 }
37+
38+// GetStats returns current connection statistics from the underlying sync connection.
39+func (c *SyncClient) GetStats() Stats {
40+ if c.syncConn == nil {
41+ return Stats{
42+ Status: c.status,
43+ }
44+ }
45+ return c.syncConn.GetStats()
46+}
47+
48+// Flush performs a best-effort sync of pending updates.
49+func (c *SyncClient) Flush(ctx context.Context) error {
50+ if c.syncConn == nil {
51+ return fmt.Errorf("not connected")
52+ }
53+ return c.syncConn.Flush(ctx)
54+}
55+
56+// GetSyncConn returns the underlying SyncConn for advanced use.
57+// This allows access to lower-level connection methods.
58+func (c *SyncClient) GetSyncConn() *SyncConn {
59+ return c.syncConn
60+}
+317,
-0
1@@ -0,0 +1,317 @@
2+package ygo
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "sync"
9+ "time"
10+
11+ ygosync "github.com/BTBurke/ygo/sync"
12+)
13+
14+// SyncHook is the standard callback signature for sync events.
15+// It receives the document and current connection statistics.
16+type SyncHook func(doc *Doc, stats SyncStats) error
17+
18+// SyncHookCtx is the context-aware callback signature for sync events.
19+// It receives a context (for cancellation/deadlines), the document, and stats.
20+type SyncHookCtx func(ctx context.Context, doc *Doc, stats SyncStats) error
21+
22+// SyncStats holds connection statistics for a sync client.
23+type SyncStats struct {
24+ // PeerCount is the number of other clients currently connected (via awareness)
25+ PeerCount int
26+
27+ // LastUpdate is the time of the last received update from the server
28+ LastUpdate time.Time
29+
30+ // PendingUpdate is true if there are local changes not yet synced to the server
31+ PendingUpdate bool
32+
33+ // Status is the current connection state
34+ Status SyncStatus
35+}
36+
37+// SyncClient manages a WebSocket connection to a y-sweet server for document synchronization.
38+// It provides explicit lifecycle control, connection statistics, and event callbacks.
39+//
40+// The user must explicitly call doc.Destroy() when done with the document - this is never
41+// done automatically, even on disconnect, to allow for retry/reconnect logic.
42+type SyncClient struct {
43+ doc *Doc
44+ inner *ygosync.SyncClient
45+ config *syncConfig
46+ onConnect SyncHookCtx
47+ onDisconnect SyncHookCtx
48+ onUpdate SyncHookCtx
49+ statsMu sync.RWMutex
50+ cachedStats SyncStats
51+}
52+
53+// NewSyncClient creates a new sync client for the document.
54+// The client is not connected yet - use Connect() to establish the connection.
55+// The returned client must be closed with Close() when done.
56+//
57+// IMPORTANT: The caller retains full ownership of the document. You must explicitly
58+// call doc.Destroy() when done. The sync client will NEVER destroy the document
59+// automatically, even on disconnect or errors, to allow for retry/reconnect logic.
60+func NewSyncClient(doc *Doc, opts ...SyncOption) (*SyncClient, error) {
61+ if doc == nil {
62+ return nil, ErrNilDocument
63+ }
64+ if doc.ptr == nil {
65+ return nil, ErrNilDocument
66+ }
67+
68+ cfg := &syncConfig{}
69+ for _, opt := range opts {
70+ opt(cfg)
71+ }
72+
73+ if cfg.endpoint == "" {
74+ return nil, ErrSyncEndpointRequired
75+ }
76+
77+ client := &SyncClient{
78+ doc: doc,
79+ config: cfg,
80+ }
81+
82+ return client, nil
83+}
84+
85+// Connect establishes the WebSocket connection to the y-sweet server.
86+// This method blocks until the connection is established or fails.
87+// Once connected, the client runs in the background until Close() is called
88+// or the context is cancelled.
89+//
90+// If the context is cancelled, the client will gracefully shutdown,
91+// flush any pending updates, and close the connection.
92+func (c *SyncClient) Connect(ctx context.Context) error {
93+ c.statsMu.Lock()
94+ defer c.statsMu.Unlock()
95+
96+ if c.inner != nil {
97+ return fmt.Errorf("already connected")
98+ }
99+
100+ // Set default disconnect timeout
101+ disconnectAfter := 5 * time.Minute
102+ if c.config.disconnectAfter > 0 {
103+ disconnectAfter = c.config.disconnectAfter
104+ }
105+
106+ // Create adapter to implement sync.DocInterface
107+ adapter := &syncDocAdapter{doc: c.doc}
108+
109+ // Build internal sync options
110+ opts := ygosync.Options{
111+ Endpoint: c.config.endpoint,
112+ AwarenessTimeout: disconnectAfter,
113+ }
114+ if c.config.token != "" {
115+ opts.AuthToken = c.config.token
116+ }
117+ if c.config.awarenessState != nil {
118+ stateBytes, _ := json.Marshal(c.config.awarenessState)
119+ opts.AwarenessState = stateBytes
120+ }
121+
122+ // Create internal sync client
123+ c.inner = ygosync.NewSyncClient(adapter,
124+ ygosync.WithEndpoint(opts.Endpoint),
125+ ygosync.WithAwarenessTimeout(opts.AwarenessTimeout))
126+
127+ if opts.AuthToken != "" {
128+ c.inner.SetAuthToken(opts.AuthToken)
129+ }
130+
131+ // Set up internal callbacks that wrap our SyncHookCtx callbacks
132+ if c.onUpdate != nil {
133+ doc := c.doc
134+ onUpdate := c.onUpdate
135+ c.inner.SetOnUpdate(func(di ygosync.DocInterface) error {
136+ stats := c.getStatsUnsafe()
137+ return onUpdate(context.Background(), doc, stats)
138+ })
139+ }
140+
141+ // Store reference in doc for awareness tracking
142+ c.doc.sync = c.inner
143+
144+ // Start connection
145+ err := c.inner.Start(ctx)
146+ if err != nil {
147+ c.inner = nil
148+ return err
149+ }
150+
151+ // Trigger OnConnect callback
152+ if c.onConnect != nil {
153+ go func() {
154+ stats := c.Stats()
155+ c.onConnect(context.Background(), c.doc, stats)
156+ }()
157+ }
158+
159+ // Monitor context for graceful shutdown
160+ go func() {
161+ <-ctx.Done()
162+ // Context cancelled - graceful shutdown with flush
163+ c.Close()
164+ }()
165+
166+ return nil
167+}
168+
169+// Close gracefully disconnects from the y-sweet server.
170+// It flushes any pending updates before closing the connection.
171+// Safe to call multiple times.
172+//
173+// Note: This does NOT destroy the document. You must call doc.Destroy() separately.
174+func (c *SyncClient) Close() error {
175+ c.statsMu.Lock()
176+ defer c.statsMu.Unlock()
177+
178+ if c.inner == nil {
179+ return nil
180+ }
181+
182+ // Trigger OnDisconnect before closing
183+ if c.onDisconnect != nil {
184+ stats := c.getStatsUnsafe()
185+ go func() {
186+ c.onDisconnect(context.Background(), c.doc, stats)
187+ }()
188+ }
189+
190+ // Send pending updates and close
191+ err := c.inner.SendPendingAndClose()
192+
193+ // Clear doc reference
194+ if c.doc != nil {
195+ c.doc.sync = nil
196+ }
197+
198+ c.inner = nil
199+
200+ return err
201+}
202+
203+// Status returns the current connection status.
204+func (c *SyncClient) Status() SyncStatus {
205+ c.statsMu.RLock()
206+ defer c.statsMu.RUnlock()
207+
208+ if c.inner == nil {
209+ return SyncStatusDisconnected
210+ }
211+
212+ return c.getStatsUnsafe().Status
213+}
214+
215+// Stats returns current connection statistics.
216+// Returns zero values if not connected.
217+func (c *SyncClient) Stats() SyncStats {
218+ c.statsMu.RLock()
219+ defer c.statsMu.RUnlock()
220+
221+ return c.getStatsUnsafe()
222+}
223+
224+// getStatsUnsafe returns stats without acquiring lock (must be called with lock held)
225+func (c *SyncClient) getStatsUnsafe() SyncStats {
226+ if c.inner == nil {
227+ return SyncStats{
228+ Status: SyncStatusDisconnected,
229+ }
230+ }
231+
232+ innerStats := c.inner.GetStats()
233+ return SyncStats{
234+ PeerCount: innerStats.PeerCount,
235+ LastUpdate: innerStats.LastUpdate,
236+ PendingUpdate: innerStats.PendingUpdate,
237+ Status: syncStatusFromInternal(innerStats.Status),
238+ }
239+}
240+
241+// Flush performs a best-effort sync of pending updates.
242+// It sends any pending local changes to the server.
243+// The context can be used to set a timeout or cancellation.
244+// Useful before shutdown to ensure changes are transmitted.
245+func (c *SyncClient) Flush(ctx context.Context) error {
246+ c.statsMu.RLock()
247+ inner := c.inner
248+ c.statsMu.RUnlock()
249+
250+ if inner == nil {
251+ return fmt.Errorf("not connected")
252+ }
253+
254+ return inner.Flush(ctx)
255+}
256+
257+// OnConnect sets a callback that is invoked when the client successfully connects.
258+// The callback receives the document and current stats.
259+func (c *SyncClient) OnConnect(fn SyncHook) {
260+ // Wrap the non-context SyncHook to call as SyncHookCtx with background context
261+ c.OnConnectCtx(func(ctx context.Context, doc *Doc, stats SyncStats) error {
262+ return fn(doc, stats)
263+ })
264+}
265+
266+// OnConnectCtx sets a context-aware callback for connect events.
267+func (c *SyncClient) OnConnectCtx(fn SyncHookCtx) {
268+ c.statsMu.Lock()
269+ defer c.statsMu.Unlock()
270+ c.onConnect = fn
271+}
272+
273+// OnDisconnect sets a callback that is invoked when the client disconnects.
274+// The callback receives the document and current stats at disconnect time.
275+// Note: This does NOT mean you should destroy the document - reconnect is possible.
276+func (c *SyncClient) OnDisconnect(fn SyncHook) {
277+ // Wrap the non-context SyncHook to call as SyncHookCtx with background context
278+ c.OnDisconnectCtx(func(ctx context.Context, doc *Doc, stats SyncStats) error {
279+ return fn(doc, stats)
280+ })
281+}
282+
283+// OnDisconnectCtx sets a context-aware callback for disconnect events.
284+func (c *SyncClient) OnDisconnectCtx(fn SyncHookCtx) {
285+ c.statsMu.Lock()
286+ defer c.statsMu.Unlock()
287+ c.onDisconnect = fn
288+}
289+
290+// OnUpdate sets a callback that is invoked when the document receives updates from peers.
291+// The callback receives the document and current stats.
292+func (c *SyncClient) OnUpdate(fn SyncHook) {
293+ // Wrap the non-context SyncHook to call as SyncHookCtx with background context
294+ c.OnUpdateCtx(func(ctx context.Context, doc *Doc, stats SyncStats) error {
295+ return fn(doc, stats)
296+ })
297+}
298+
299+// OnUpdateCtx sets a context-aware callback for update events.
300+func (c *SyncClient) OnUpdateCtx(fn SyncHookCtx) {
301+ c.statsMu.Lock()
302+ defer c.statsMu.Unlock()
303+ c.onUpdate = fn
304+}
305+
306+// syncStatusFromInternal converts internal ygosync.Status to public SyncStatus
307+func syncStatusFromInternal(s ygosync.Status) SyncStatus {
308+ switch s {
309+ case ygosync.StatusConnecting:
310+ return SyncStatusConnecting
311+ case ygosync.StatusConnected:
312+ return SyncStatusConnected
313+ case ygosync.StatusDisconnecting:
314+ return SyncStatusDisconnecting
315+ default:
316+ return SyncStatusDisconnected
317+ }
318+}
+165,
-0
1@@ -0,0 +1,165 @@
2+package ygo_test
3+
4+import (
5+ "context"
6+ "testing"
7+ "time"
8+
9+ "github.com/BTBurke/ygo"
10+)
11+
12+func TestNewSyncClient(t *testing.T) {
13+ // Test without endpoint
14+ doc, _ := ygo.NewDoc()
15+ defer doc.Destroy()
16+
17+ _, err := ygo.NewSyncClient(doc)
18+ if err == nil {
19+ t.Error("expected error without endpoint")
20+ }
21+
22+ // Test with endpoint
23+ client, err := ygo.NewSyncClient(doc, ygo.WithSyncEndpoint("ws://test"))
24+ if err != nil {
25+ t.Fatalf("failed to create client: %v", err)
26+ }
27+
28+ // Verify not connected yet
29+ if client.Status() != ygo.SyncStatusDisconnected {
30+ t.Errorf("expected disconnected, got %v", client.Status())
31+ }
32+
33+ // Stats should show disconnected
34+ stats := client.Stats()
35+ if stats.Status != ygo.SyncStatusDisconnected {
36+ t.Errorf("expected disconnected in stats, got %v", stats.Status)
37+ }
38+}
39+
40+func TestSyncClientEventHooks(t *testing.T) {
41+ doc, _ := ygo.NewDoc()
42+ defer doc.Destroy()
43+
44+ client, _ := ygo.NewSyncClient(doc, ygo.WithSyncEndpoint("ws://test"))
45+
46+ // Test SyncHook (non-context) callbacks
47+ connectCalled := false
48+ disconnectCalled := false
49+ updateCalled := false
50+
51+ client.OnConnect(func(doc *ygo.Doc, stats ygo.SyncStats) error {
52+ connectCalled = true
53+ return nil
54+ })
55+
56+ client.OnDisconnect(func(doc *ygo.Doc, stats ygo.SyncStats) error {
57+ disconnectCalled = true
58+ return nil
59+ })
60+
61+ client.OnUpdate(func(doc *ygo.Doc, stats ygo.SyncStats) error {
62+ updateCalled = true
63+ return nil
64+ })
65+
66+ // Callbacks are set but not triggered yet (since not connected)
67+ if connectCalled {
68+ t.Error("connect should not be called yet")
69+ }
70+ if disconnectCalled {
71+ t.Error("disconnect should not be called yet")
72+ }
73+ if updateCalled {
74+ t.Error("update should not be called yet")
75+ }
76+}
77+
78+func TestSyncClientEventCtxHooks(t *testing.T) {
79+ doc, _ := ygo.NewDoc()
80+ defer doc.Destroy()
81+
82+ client, _ := ygo.NewSyncClient(doc, ygo.WithSyncEndpoint("ws://test"))
83+
84+ // Test SyncHookCtx (context-aware) callbacks
85+ connectCtxCalled := false
86+ disconnectCtxCalled := false
87+ updateCtxCalled := false
88+
89+ client.OnConnectCtx(func(ctx context.Context, doc *ygo.Doc, stats ygo.SyncStats) error {
90+ connectCtxCalled = true
91+ return nil
92+ })
93+
94+ client.OnDisconnectCtx(func(ctx context.Context, doc *ygo.Doc, stats ygo.SyncStats) error {
95+ disconnectCtxCalled = true
96+ return nil
97+ })
98+
99+ client.OnUpdateCtx(func(ctx context.Context, doc *ygo.Doc, stats ygo.SyncStats) error {
100+ updateCtxCalled = true
101+ return nil
102+ })
103+
104+ // Callbacks are set but not triggered yet
105+ if connectCtxCalled {
106+ t.Error("connect ctx should not be called yet")
107+ }
108+ if disconnectCtxCalled {
109+ t.Error("disconnect ctx should not be called yet")
110+ }
111+ if updateCtxCalled {
112+ t.Error("update ctx should not be called yet")
113+ }
114+}
115+
116+func TestSyncClientFlushNotConnected(t *testing.T) {
117+ doc, _ := ygo.NewDoc()
118+ defer doc.Destroy()
119+
120+ client, _ := ygo.NewSyncClient(doc, ygo.WithSyncEndpoint("ws://test"))
121+
122+ // Flush should fail when not connected
123+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
124+ defer cancel()
125+
126+ err := client.Flush(ctx)
127+ if err == nil {
128+ t.Error("expected error when flushing while disconnected")
129+ }
130+}
131+
132+func TestSyncClientCloseNotConnected(t *testing.T) {
133+ doc, _ := ygo.NewDoc()
134+ defer doc.Destroy()
135+
136+ client, _ := ygo.NewSyncClient(doc, ygo.WithSyncEndpoint("ws://test"))
137+
138+ // Close should succeed even when not connected (no-op)
139+ err := client.Close()
140+ if err != nil {
141+ t.Errorf("expected no error when closing disconnected client, got %v", err)
142+ }
143+}
144+
145+func TestSyncStatsStruct(t *testing.T) {
146+ // Test that SyncStats fields exist and are accessible
147+ stats := ygo.SyncStats{
148+ PeerCount: 5,
149+ LastUpdate: time.Now(),
150+ PendingUpdate: true,
151+ Status: ygo.SyncStatusConnected,
152+ }
153+
154+ if stats.PeerCount != 5 {
155+ t.Errorf("expected PeerCount 5, got %d", stats.PeerCount)
156+ }
157+ if stats.LastUpdate.IsZero() {
158+ t.Error("expected LastUpdate to be set")
159+ }
160+ if !stats.PendingUpdate {
161+ t.Error("expected PendingUpdate to be true")
162+ }
163+ if stats.Status != ygo.SyncStatusConnected {
164+ t.Errorf("expected Status Connected, got %v", stats.Status)
165+ }
166+}