Improve API for sync, exposing more hooks and information
5 files changed,  +827, -6
M README.md
+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:
M sync/conn.go
+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+}
M sync/types.go
+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+}
A sync_client.go
+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+}
A sync_client_test.go
+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+}