Skip to main content

nodedb_lite/sync/
client.rs

1//! WebSocket sync client for edge ↔ Origin communication.
2//!
3//! Runs as a background task. Handles:
4//! - Connection with auto-reconnect (exponential backoff, 1s→60s cap)
5//! - JWT-authenticated handshake with vector clock exchange
6//! - Delta push (batched, dedup by mutation_id)
7//! - Delta/shape receive from Origin
8//! - Compensation handling on rejection
9//! - Keepalive ping/pong
10
11use std::sync::Arc;
12use std::time::Duration;
13
14use tokio::sync::Mutex;
15
16use nodedb_types::sync::wire::{
17    DeltaAckMsg, DeltaPushMsg, DeltaRejectMsg, HandshakeAckMsg, HandshakeMsg, PingPongMsg,
18    ResyncReason, ResyncRequestMsg, ShapeDeltaMsg, ShapeSnapshotMsg, SyncFrame, SyncMessageType,
19    VectorClockSyncMsg,
20};
21
22use super::clock::VectorClock;
23use super::compensation::{CompensationEvent, CompensationRegistry};
24use super::flow_control::{FlowControlConfig, FlowController, SyncMetrics, SyncMetricsSnapshot};
25use super::shapes::ShapeManager;
26use crate::engine::crdt::engine::PendingDelta;
27
28/// Token provider callback type.
29///
30/// Called when the sync client needs a fresh JWT token — either proactively
31/// before expiry or reactively after an auth rejection. The provider should
32/// return a fresh JWT token string.
33///
34/// # Example
35/// ```ignore
36/// let provider: TokenProvider = Arc::new(|| Box::pin(async {
37///     my_auth_service.get_token().await
38/// }));
39/// ```
40pub type TokenProvider = Arc<
41    dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>
42        + Send
43        + Sync,
44>;
45
46/// Sync client configuration.
47#[derive(Clone)]
48pub struct SyncConfig {
49    /// WebSocket URL to the Origin sync endpoint (e.g., `wss://api.nodedb.cloud/sync`).
50    pub url: String,
51    /// JWT bearer token for initial authentication.
52    pub jwt_token: String,
53    /// Client version string (sent in handshake).
54    pub client_version: String,
55    /// Minimum backoff on reconnect.
56    pub min_backoff: Duration,
57    /// Maximum backoff on reconnect.
58    pub max_backoff: Duration,
59    /// Keepalive ping interval.
60    pub ping_interval: Duration,
61    /// Maximum deltas to batch in a single push.
62    pub max_batch_size: usize,
63    /// Token provider for automatic refresh. If `None`, no auto-refresh occurs.
64    pub token_provider: Option<TokenProvider>,
65    /// Token lifetime in seconds (used to schedule proactive refresh at 80%).
66    /// If 0, no proactive refresh occurs — only reactive on auth rejection.
67    pub token_lifetime_secs: u64,
68}
69
70impl std::fmt::Debug for SyncConfig {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("SyncConfig")
73            .field("url", &self.url)
74            .field("jwt_token", &"[REDACTED]")
75            .field("client_version", &self.client_version)
76            .field("min_backoff", &self.min_backoff)
77            .field("max_backoff", &self.max_backoff)
78            .field("ping_interval", &self.ping_interval)
79            .field("max_batch_size", &self.max_batch_size)
80            .field("token_provider", &self.token_provider.is_some())
81            .field("token_lifetime_secs", &self.token_lifetime_secs)
82            .finish()
83    }
84}
85
86impl SyncConfig {
87    pub fn new(url: impl Into<String>, jwt_token: impl Into<String>) -> Self {
88        Self {
89            url: url.into(),
90            jwt_token: jwt_token.into(),
91            client_version: env!("CARGO_PKG_VERSION").to_string(),
92            min_backoff: Duration::from_secs(1),
93            max_backoff: Duration::from_secs(60),
94            ping_interval: Duration::from_secs(30),
95            max_batch_size: 100,
96            token_provider: None,
97            token_lifetime_secs: 0,
98        }
99    }
100
101    /// Set a token provider for automatic JWT refresh.
102    pub fn with_token_provider(mut self, provider: TokenProvider, lifetime_secs: u64) -> Self {
103        self.token_provider = Some(provider);
104        self.token_lifetime_secs = lifetime_secs;
105        self
106    }
107}
108
109/// Connection state.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum SyncState {
112    /// Not connected, not trying.
113    Disconnected,
114    /// Attempting to connect.
115    Connecting,
116    /// Connected and authenticated.
117    Connected,
118    /// Connection lost, backing off before retry.
119    Reconnecting,
120}
121
122/// Sync client — manages the WebSocket connection to Origin.
123///
124/// The client runs as a background Tokio task. It:
125/// 1. Connects to Origin via WebSocket
126/// 2. Sends handshake with JWT + vector clock + shape subscriptions
127/// 3. Pushes accumulated CRDT deltas
128/// 4. Receives shape snapshots and incremental deltas
129/// 5. Handles rejections via CompensationRegistry
130/// 6. Auto-reconnects with exponential backoff on disconnect
131pub struct SyncClient {
132    config: SyncConfig,
133    state: Arc<Mutex<SyncState>>,
134    clock: Arc<Mutex<VectorClock>>,
135    shapes: Arc<Mutex<ShapeManager>>,
136    compensation: Arc<CompensationRegistry>,
137    /// Session ID assigned by Origin after handshake.
138    session_id: Arc<Mutex<Option<String>>>,
139    /// Peer ID of this Lite client (for CRDT identity).
140    peer_id: u64,
141    /// Lite instance identity (UUID v7) for fork detection.
142    lite_id: Option<String>,
143    /// Monotonic epoch counter for fork detection.
144    epoch: Option<u64>,
145    /// Sequence tracker: per-shape, the last LSN received from Origin.
146    /// Used to detect gaps in the incoming delta stream.
147    last_seen_lsn: Arc<Mutex<std::collections::HashMap<String, u64>>>,
148    /// Whether a re-sync request has been sent for this connection.
149    /// Prevents flooding Origin with multiple re-sync requests.
150    resync_requested: Arc<Mutex<bool>>,
151    /// Pending re-sync request to send to Origin (set by gap detection,
152    /// consumed by the delta push loop).
153    pending_resync: Arc<Mutex<Option<ResyncRequestMsg>>>,
154    /// Flow controller: in-flight window, adaptive batch sizing, queue bounds.
155    flow: Arc<Mutex<FlowController>>,
156    /// Sync metrics: atomic counters for monitoring.
157    metrics: Arc<SyncMetrics>,
158    /// Timestamp (epoch ms) when the current JWT was set (for proactive refresh).
159    token_set_at_ms: Arc<Mutex<u64>>,
160    /// Whether a token refresh is currently in-flight.
161    token_refresh_pending: Arc<Mutex<bool>>,
162    /// Whether delta push is paused due to auth failure (awaiting refresh).
163    push_paused_for_auth: Arc<Mutex<bool>>,
164}
165
166impl SyncClient {
167    /// Create a new sync client (does not connect yet).
168    pub fn new(config: SyncConfig, peer_id: u64) -> Self {
169        Self::with_flow_control(config, peer_id, FlowControlConfig::default())
170    }
171
172    /// Create a new sync client with custom flow control config.
173    pub fn with_flow_control(
174        config: SyncConfig,
175        peer_id: u64,
176        flow_config: FlowControlConfig,
177    ) -> Self {
178        Self {
179            config,
180            state: Arc::new(Mutex::new(SyncState::Disconnected)),
181            clock: Arc::new(Mutex::new(VectorClock::new())),
182            shapes: Arc::new(Mutex::new(ShapeManager::new())),
183            compensation: Arc::new(CompensationRegistry::new()),
184            session_id: Arc::new(Mutex::new(None)),
185            peer_id,
186            lite_id: None,
187            epoch: None,
188            last_seen_lsn: Arc::new(Mutex::new(std::collections::HashMap::new())),
189            resync_requested: Arc::new(Mutex::new(false)),
190            pending_resync: Arc::new(Mutex::new(None)),
191            flow: Arc::new(Mutex::new(FlowController::new(flow_config))),
192            metrics: Arc::new(SyncMetrics::new()),
193            token_set_at_ms: Arc::new(Mutex::new(crate::runtime::now_millis())),
194            token_refresh_pending: Arc::new(Mutex::new(false)),
195            push_paused_for_auth: Arc::new(Mutex::new(false)),
196        }
197    }
198
199    /// Set the Lite identity for fork detection (called after LiteIdentity::load_or_create).
200    pub fn set_identity(&mut self, lite_id: String, epoch: u64) {
201        self.lite_id = Some(lite_id);
202        self.epoch = Some(epoch);
203    }
204
205    /// Current connection state.
206    pub async fn state(&self) -> SyncState {
207        *self.state.lock().await
208    }
209
210    /// Register a compensation handler.
211    pub fn set_compensation_handler(
212        &self,
213        handler: Arc<dyn super::compensation::CompensationHandler>,
214    ) {
215        self.compensation.set_handler(handler);
216    }
217
218    /// Access the shape manager (for subscribing/unsubscribing).
219    pub fn shapes(&self) -> &Arc<Mutex<ShapeManager>> {
220        &self.shapes
221    }
222
223    /// Access the vector clock.
224    pub fn clock(&self) -> &Arc<Mutex<VectorClock>> {
225        &self.clock
226    }
227
228    /// Build a handshake message from current state.
229    pub async fn build_handshake(&self) -> HandshakeMsg {
230        let clock = self.clock.lock().await;
231        let shapes = self.shapes.lock().await;
232
233        // Convert our VectorClock to the wire format expected by Origin.
234        let wire_clock = clock.to_wire();
235        let mut vector_clock = std::collections::HashMap::new();
236        // Origin expects: { collection: { doc_id: lamport_ts } }
237        // We send a simplified version: { "_global": { peer_hex: counter } }
238        vector_clock.insert("_global".to_string(), wire_clock);
239
240        HandshakeMsg {
241            jwt_token: self.config.jwt_token.clone(),
242            vector_clock,
243            subscribed_shapes: shapes.active_shape_ids(),
244            client_version: self.config.client_version.clone(),
245            lite_id: self.lite_id.clone().unwrap_or_default(),
246            epoch: self.epoch.unwrap_or(0),
247            wire_version: 1,
248        }
249    }
250
251    /// Process a handshake acknowledgment from Origin.
252    pub async fn handle_handshake_ack(&self, ack: &HandshakeAckMsg) -> bool {
253        if !ack.success {
254            tracing::warn!(
255                error = ack.error.as_deref().unwrap_or("unknown"),
256                "handshake rejected by Origin"
257            );
258            return false;
259        }
260
261        // Store session ID.
262        *self.session_id.lock().await = Some(ack.session_id.clone());
263
264        // Update our clock with Origin's.
265        let mut clock = self.clock.lock().await;
266        for (peer_hex, &counter) in &ack.server_clock {
267            if let Ok(peer_id) = u64::from_str_radix(peer_hex, 16) {
268                clock.advance(peer_id, counter);
269            }
270        }
271
272        *self.state.lock().await = SyncState::Connected;
273        tracing::info!(session = %ack.session_id, "sync handshake accepted");
274        true
275    }
276
277    /// Build DeltaPush messages from pending deltas.
278    ///
279    /// Respects the flow control window: returns at most `next_batch_size()`
280    /// deltas. Each message includes a CRC32C checksum of the delta payload
281    /// for integrity verification at Origin.
282    pub async fn build_delta_pushes(&self, pending: &[PendingDelta]) -> Vec<DeltaPushMsg> {
283        let flow = self.flow.lock().await;
284        let batch_limit = flow.next_batch_size();
285        drop(flow);
286
287        if batch_limit == 0 {
288            return Vec::new();
289        }
290
291        pending
292            .iter()
293            .take(batch_limit)
294            .map(|delta| DeltaPushMsg {
295                collection: delta.collection.clone(),
296                document_id: delta.document_id.clone(),
297                checksum: crc32c::crc32c(&delta.delta_bytes),
298                delta: delta.delta_bytes.clone(),
299                peer_id: self.peer_id,
300                mutation_id: delta.mutation_id,
301            })
302            .collect()
303    }
304
305    /// Record that deltas were pushed (update flow control in-flight tracking).
306    pub async fn record_push(&self, mutation_ids: &[u64]) {
307        let mut flow = self.flow.lock().await;
308        flow.record_push(mutation_ids);
309        self.metrics.record_push(mutation_ids.len() as u64);
310    }
311
312    /// Process a DeltaAck from Origin.
313    pub async fn handle_delta_ack(&self, ack: &DeltaAckMsg) {
314        let mut clock = self.clock.lock().await;
315        // The Origin assigned an LSN — advance our view of Origin's state.
316        clock.advance(0, ack.lsn); // peer 0 = Origin convention.
317        drop(clock);
318
319        // Update flow controller: record RTT for adaptive batch sizing.
320        let mut flow = self.flow.lock().await;
321        if let Some(rtt_ms) = flow.record_ack(ack.mutation_id) {
322            tracing::debug!(
323                mutation_id = ack.mutation_id,
324                lsn = ack.lsn,
325                rtt_ms,
326                batch_size = flow.current_batch_size(),
327                "delta acknowledged"
328            );
329        } else {
330            tracing::debug!(
331                mutation_id = ack.mutation_id,
332                lsn = ack.lsn,
333                "delta acknowledged (no in-flight entry)"
334            );
335        }
336    }
337
338    /// Process a DeltaReject from Origin.
339    pub async fn handle_delta_reject(&self, reject: &DeltaRejectMsg) {
340        tracing::warn!(
341            mutation_id = reject.mutation_id,
342            reason = %reject.reason,
343            "delta rejected by Origin"
344        );
345
346        // Update flow controller: AIMD multiplicative decrease.
347        {
348            let mut flow = self.flow.lock().await;
349            flow.record_reject(reject.mutation_id);
350        }
351        self.metrics.record_reject();
352
353        // Track conflict telemetry for constraint-related rejections.
354        if let Some(hint) = &reject.compensation {
355            use nodedb_types::sync::compensation::CompensationHint;
356            let is_conflict = matches!(
357                hint,
358                CompensationHint::UniqueViolation { .. }
359                    | CompensationHint::ForeignKeyMissing { .. }
360                    | CompensationHint::SchemaViolation { .. }
361            );
362            if is_conflict {
363                // We don't have the collection in the reject msg, use reason as fallback.
364                self.metrics.record_conflict(&reject.reason);
365            }
366
367            self.compensation.dispatch(CompensationEvent {
368                mutation_id: reject.mutation_id,
369                collection: String::new(),
370                document_id: String::new(),
371                hint: hint.clone(),
372            });
373        }
374    }
375
376    /// Process a ShapeSnapshot from Origin.
377    pub async fn handle_shape_snapshot(&self, msg: &ShapeSnapshotMsg) {
378        let mut shapes = self.shapes.lock().await;
379        shapes.mark_snapshot_loaded(&msg.shape_id, msg.snapshot_lsn);
380        tracing::info!(
381            shape_id = %msg.shape_id,
382            lsn = msg.snapshot_lsn,
383            doc_count = msg.doc_count,
384            "shape snapshot received"
385        );
386    }
387
388    /// Process a ShapeDelta from Origin.
389    pub async fn handle_shape_delta(&self, msg: &ShapeDeltaMsg) {
390        let mut shapes = self.shapes.lock().await;
391        shapes.advance_lsn(&msg.shape_id, msg.lsn);
392        tracing::debug!(
393            shape_id = %msg.shape_id,
394            collection = %msg.collection,
395            doc_id = %msg.document_id,
396            lsn = msg.lsn,
397            "shape delta received"
398        );
399    }
400
401    /// Process a VectorClockSync from Origin.
402    pub async fn handle_clock_sync(&self, msg: &VectorClockSyncMsg) {
403        let mut clock = self.clock.lock().await;
404        for (peer_hex, &counter) in &msg.clocks {
405            if let Ok(peer_id) = u64::from_str_radix(peer_hex, 16) {
406                clock.advance(peer_id, counter);
407            }
408        }
409    }
410
411    /// Check an incoming ShapeDelta for sequence gaps.
412    ///
413    /// For each shape, we track the last LSN received. If the incoming LSN
414    /// is not contiguous (gap > 1), this indicates missing deltas in the stream.
415    /// Returns `Some(ResyncRequestMsg)` if a gap is detected, `None` otherwise.
416    ///
417    /// Note: LSNs may not be strictly +1 sequential (Origin may skip LSNs for
418    /// other shapes), so we only flag a gap when the new LSN is MORE than 1
419    /// ahead of the last seen LSN for the SAME shape. A gap means deltas were
420    /// lost in transit.
421    pub async fn check_sequence_gap(&self, shape_id: &str, lsn: u64) -> Option<ResyncRequestMsg> {
422        // Don't send multiple re-sync requests per connection.
423        if *self.resync_requested.lock().await {
424            return None;
425        }
426
427        let mut tracker = self.last_seen_lsn.lock().await;
428        if let Some(&last_lsn) = tracker.get(shape_id)
429            && lsn > last_lsn + 1
430        {
431            // Gap detected: we expected last_lsn+1 but got lsn.
432            tracing::warn!(
433                shape_id,
434                expected = last_lsn + 1,
435                received = lsn,
436                "sequence gap detected in incoming delta stream"
437            );
438            tracker.insert(shape_id.to_string(), lsn);
439
440            // Mark that we've requested re-sync for this connection.
441            *self.resync_requested.lock().await = true;
442
443            return Some(ResyncRequestMsg {
444                reason: ResyncReason::SequenceGap {
445                    expected: last_lsn + 1,
446                    received: lsn,
447                },
448                from_mutation_id: last_lsn + 1,
449                collection: String::new(), // All collections.
450            });
451        }
452        tracker.insert(shape_id.to_string(), lsn);
453        None
454    }
455
456    /// Reset sequence tracking state on reconnect.
457    pub async fn reset_sequence_tracking(&self) {
458        self.last_seen_lsn.lock().await.clear();
459        *self.resync_requested.lock().await = false;
460        *self.pending_resync.lock().await = None;
461    }
462
463    /// Store a pending re-sync request (set by gap detection in receive loop).
464    pub async fn set_pending_resync(&self, msg: ResyncRequestMsg) {
465        *self.pending_resync.lock().await = Some(msg);
466    }
467
468    /// Take the pending re-sync request (consumed by delta push loop).
469    pub async fn take_pending_resync(&self) -> Option<ResyncRequestMsg> {
470        self.pending_resync.lock().await.take()
471    }
472
473    /// Build a ping frame.
474    pub fn build_ping(&self) -> SyncFrame {
475        let ping = PingPongMsg {
476            timestamp_ms: crate::runtime::now_millis(),
477            is_pong: false,
478        };
479        SyncFrame::encode_or_empty(SyncMessageType::PingPong, &ping)
480    }
481
482    /// Calculate backoff duration for reconnection attempt N.
483    pub fn backoff_duration(&self, attempt: u32) -> Duration {
484        let base = self.config.min_backoff.as_millis() as u64;
485        let max = self.config.max_backoff.as_millis() as u64;
486        let delay = (base * 2u64.saturating_pow(attempt)).min(max);
487        Duration::from_millis(delay)
488    }
489
490    /// Set the connection state.
491    pub async fn set_state(&self, new_state: SyncState) {
492        *self.state.lock().await = new_state;
493    }
494
495    /// Access the compensation registry.
496    pub fn compensation(&self) -> &Arc<CompensationRegistry> {
497        &self.compensation
498    }
499
500    /// Access config.
501    pub fn config(&self) -> &SyncConfig {
502        &self.config
503    }
504
505    /// Peer ID.
506    pub fn peer_id(&self) -> u64 {
507        self.peer_id
508    }
509
510    /// Access the flow controller.
511    pub fn flow(&self) -> &Arc<Mutex<FlowController>> {
512        &self.flow
513    }
514
515    /// Access the sync metrics.
516    pub fn metrics(&self) -> &Arc<SyncMetrics> {
517        &self.metrics
518    }
519
520    /// Update pending queue stats in the flow controller.
521    /// Called from the push loop after reading pending deltas.
522    pub async fn update_pending_stats(&self, count: usize, bytes: usize) {
523        let mut flow = self.flow.lock().await;
524        flow.update_pending(count, bytes);
525    }
526
527    /// Check if the pending queue is at capacity (flow control).
528    pub async fn is_queue_full(&self) -> bool {
529        let flow = self.flow.lock().await;
530        flow.is_queue_full()
531    }
532
533    /// Get a snapshot of sync metrics for monitoring/health.
534    pub async fn sync_metrics(&self) -> SyncMetricsSnapshot {
535        let state = *self.state.lock().await;
536        let state_str = match state {
537            SyncState::Disconnected => "disconnected",
538            SyncState::Connecting => "connecting",
539            SyncState::Connected => "connected",
540            SyncState::Reconnecting => "reconnecting",
541        };
542        let flow = self.flow.lock().await;
543        flow.snapshot(state_str, &self.metrics)
544    }
545
546    /// Reset flow controller on reconnect.
547    pub async fn reset_flow_control(&self) {
548        let mut flow = self.flow.lock().await;
549        flow.reset();
550        self.metrics.record_reconnect();
551    }
552
553    // ─── Token Refresh ──────────────────────────────────────────────
554
555    /// Check if the JWT token needs proactive refresh (at 80% of lifetime).
556    ///
557    /// Returns `true` if a refresh should be initiated. Called from the
558    /// ping loop to piggyback on the keepalive timer.
559    pub async fn should_refresh_token(&self) -> bool {
560        if self.config.token_provider.is_none() || self.config.token_lifetime_secs == 0 {
561            return false;
562        }
563        if *self.token_refresh_pending.lock().await {
564            return false;
565        }
566        let set_at = *self.token_set_at_ms.lock().await;
567        let now = crate::runtime::now_millis();
568        let elapsed_ms = now.saturating_sub(set_at);
569        let threshold_ms = self.config.token_lifetime_secs * 800; // 80% of lifetime
570        elapsed_ms >= threshold_ms
571    }
572
573    /// Initiate a token refresh via the token provider.
574    ///
575    /// Returns `Some(TokenRefreshMsg)` with the new token if the provider
576    /// returned one, or `None` if the provider failed.
577    pub async fn initiate_token_refresh(
578        &self,
579    ) -> Option<nodedb_types::sync::wire::TokenRefreshMsg> {
580        let provider = self.config.token_provider.as_ref()?;
581        *self.token_refresh_pending.lock().await = true;
582
583        tracing::info!("initiating proactive JWT token refresh");
584        let new_token = provider().await?;
585
586        Some(nodedb_types::sync::wire::TokenRefreshMsg { new_token })
587    }
588
589    /// Handle a TokenRefreshAck from Origin.
590    pub async fn handle_token_refresh_ack(
591        &self,
592        ack: &nodedb_types::sync::wire::TokenRefreshAckMsg,
593    ) {
594        *self.token_refresh_pending.lock().await = false;
595
596        if ack.success {
597            *self.token_set_at_ms.lock().await = crate::runtime::now_millis();
598            *self.push_paused_for_auth.lock().await = false;
599            tracing::info!(
600                expires_in_secs = ack.expires_in_secs,
601                "JWT token refresh accepted by Origin"
602            );
603        } else {
604            tracing::warn!(
605                error = ack.error.as_deref().unwrap_or("unknown"),
606                "JWT token refresh rejected by Origin"
607            );
608        }
609    }
610
611    /// Pause delta push due to auth failure. Called when Origin rejects
612    /// with PermissionDenied, indicating the token has expired.
613    pub async fn pause_for_auth(&self) {
614        *self.push_paused_for_auth.lock().await = true;
615        tracing::warn!("delta push paused — auth failure, awaiting token refresh");
616    }
617
618    /// Check if push is paused for auth.
619    pub async fn is_push_paused_for_auth(&self) -> bool {
620        *self.push_paused_for_auth.lock().await
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    fn make_config() -> SyncConfig {
629        SyncConfig::new("wss://localhost:9090/sync", "test.jwt.token")
630    }
631
632    #[tokio::test]
633    async fn initial_state_is_disconnected() {
634        let client = SyncClient::new(make_config(), 1);
635        assert_eq!(client.state().await, SyncState::Disconnected);
636    }
637
638    #[tokio::test]
639    async fn build_handshake() {
640        let client = SyncClient::new(make_config(), 1);
641
642        {
643            let mut shapes = client.shapes().lock().await;
644            shapes.subscribe(nodedb_types::sync::shape::ShapeDefinition {
645                shape_id: "s1".into(),
646                tenant_id: 1,
647                shape_type: nodedb_types::sync::shape::ShapeType::Document {
648                    collection: "orders".into(),
649                    predicate: Vec::new(),
650                },
651                description: "test".into(),
652                field_filter: vec![],
653            });
654        }
655
656        let hs = client.build_handshake().await;
657        assert_eq!(hs.jwt_token, "test.jwt.token");
658        assert_eq!(hs.subscribed_shapes, vec!["s1"]);
659    }
660
661    #[tokio::test]
662    async fn handle_handshake_ack_success() {
663        let client = SyncClient::new(make_config(), 1);
664        let ack = HandshakeAckMsg {
665            success: true,
666            session_id: "sess-123".into(),
667            server_clock: std::collections::HashMap::new(),
668            error: None,
669            fork_detected: false,
670            server_wire_version: 1,
671        };
672
673        assert!(client.handle_handshake_ack(&ack).await);
674        assert_eq!(client.state().await, SyncState::Connected);
675    }
676
677    #[tokio::test]
678    async fn handle_handshake_ack_failure() {
679        let client = SyncClient::new(make_config(), 1);
680        let ack = HandshakeAckMsg {
681            success: false,
682            session_id: String::new(),
683            server_clock: std::collections::HashMap::new(),
684            error: Some("invalid token".into()),
685            fork_detected: false,
686            server_wire_version: 1,
687        };
688
689        assert!(!client.handle_handshake_ack(&ack).await);
690        assert_eq!(client.state().await, SyncState::Disconnected);
691    }
692
693    #[tokio::test]
694    async fn build_delta_pushes() {
695        let client = SyncClient::new(make_config(), 42);
696        let pending = vec![
697            PendingDelta {
698                mutation_id: 1,
699                collection: "orders".into(),
700                document_id: "o1".into(),
701                delta_bytes: vec![1, 2, 3],
702            },
703            PendingDelta {
704                mutation_id: 2,
705                collection: "users".into(),
706                document_id: "u1".into(),
707                delta_bytes: vec![4, 5, 6],
708            },
709        ];
710
711        let msgs = client.build_delta_pushes(&pending).await;
712        assert_eq!(msgs.len(), 2);
713        assert_eq!(msgs[0].peer_id, 42);
714        assert_eq!(msgs[0].mutation_id, 1);
715        assert_eq!(msgs[1].collection, "users");
716    }
717
718    #[tokio::test]
719    async fn handle_delta_ack_advances_clock() {
720        let client = SyncClient::new(make_config(), 1);
721        client
722            .handle_delta_ack(&DeltaAckMsg {
723                mutation_id: 1,
724                lsn: 42,
725            })
726            .await;
727
728        let clock = client.clock().lock().await;
729        assert_eq!(clock.get(0), 42); // peer 0 = Origin.
730    }
731
732    #[tokio::test]
733    async fn handle_delta_reject_dispatches_compensation() {
734        let client = SyncClient::new(make_config(), 1);
735
736        let count = Arc::new(std::sync::atomic::AtomicU32::new(0));
737        let count_clone = count.clone();
738        client.set_compensation_handler(Arc::new(move |_: CompensationEvent| {
739            count_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
740        }));
741
742        client
743            .handle_delta_reject(&DeltaRejectMsg {
744                mutation_id: 1,
745                reason: "unique violation".into(),
746                compensation: Some(
747                    nodedb_types::sync::compensation::CompensationHint::UniqueViolation {
748                        field: "email".into(),
749                        conflicting_value: "a@b.com".into(),
750                    },
751                ),
752            })
753            .await;
754
755        assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 1);
756    }
757
758    #[test]
759    fn backoff_exponential_with_cap() {
760        let client = SyncClient::new(make_config(), 1);
761        assert_eq!(client.backoff_duration(0), Duration::from_secs(1));
762        assert_eq!(client.backoff_duration(1), Duration::from_secs(2));
763        assert_eq!(client.backoff_duration(2), Duration::from_secs(4));
764        assert_eq!(client.backoff_duration(3), Duration::from_secs(8));
765        // Should cap at max_backoff (60s).
766        assert_eq!(client.backoff_duration(10), Duration::from_secs(60));
767    }
768
769    #[tokio::test]
770    async fn shape_snapshot_updates_manager() {
771        let client = SyncClient::new(make_config(), 1);
772        {
773            let mut shapes = client.shapes().lock().await;
774            shapes.subscribe(nodedb_types::sync::shape::ShapeDefinition {
775                shape_id: "s1".into(),
776                tenant_id: 1,
777                shape_type: nodedb_types::sync::shape::ShapeType::Vector {
778                    collection: "vecs".into(),
779                    field_name: None,
780                },
781                description: "test".into(),
782                field_filter: vec![],
783            });
784        }
785
786        client
787            .handle_shape_snapshot(&ShapeSnapshotMsg {
788                shape_id: "s1".into(),
789                data: Vec::new(),
790                snapshot_lsn: 100,
791                doc_count: 50,
792            })
793            .await;
794
795        let shapes = client.shapes().lock().await;
796        let sub = shapes.get("s1").unwrap();
797        assert!(sub.snapshot_loaded);
798        assert_eq!(sub.last_lsn, 100);
799    }
800
801    #[test]
802    fn ping_frame_is_valid() {
803        let client = SyncClient::new(make_config(), 1);
804        let frame = client.build_ping();
805        assert_eq!(frame.msg_type, SyncMessageType::PingPong);
806        assert!(!frame.body.is_empty());
807    }
808
809    #[tokio::test]
810    async fn sequence_gap_detection_no_gap() {
811        let client = SyncClient::new(make_config(), 1);
812        // Sequential LSNs — no gap.
813        assert!(client.check_sequence_gap("s1", 1).await.is_none());
814        assert!(client.check_sequence_gap("s1", 2).await.is_none());
815        assert!(client.check_sequence_gap("s1", 3).await.is_none());
816    }
817
818    #[tokio::test]
819    async fn sequence_gap_detection_with_gap() {
820        let client = SyncClient::new(make_config(), 1);
821        // First delta at LSN 1.
822        assert!(client.check_sequence_gap("s1", 1).await.is_none());
823        // Gap: expected 2, got 5.
824        let resync = client.check_sequence_gap("s1", 5).await;
825        assert!(resync.is_some());
826        let msg = resync.unwrap();
827        assert_eq!(msg.from_mutation_id, 2); // Resume from missing LSN.
828        assert!(matches!(
829            msg.reason,
830            ResyncReason::SequenceGap {
831                expected: 2,
832                received: 5
833            }
834        ));
835    }
836
837    #[tokio::test]
838    async fn sequence_gap_only_one_resync_per_connection() {
839        let client = SyncClient::new(make_config(), 1);
840        assert!(client.check_sequence_gap("s1", 1).await.is_none());
841        // First gap — triggers resync.
842        assert!(client.check_sequence_gap("s1", 10).await.is_some());
843        // Second gap — suppressed (already requested).
844        assert!(client.check_sequence_gap("s1", 20).await.is_none());
845    }
846
847    #[tokio::test]
848    async fn reset_sequence_tracking_clears_state() {
849        let client = SyncClient::new(make_config(), 1);
850        assert!(client.check_sequence_gap("s1", 1).await.is_none());
851        assert!(client.check_sequence_gap("s1", 10).await.is_some());
852
853        // Reset (simulates reconnect).
854        client.reset_sequence_tracking().await;
855
856        // Should work again after reset.
857        assert!(client.check_sequence_gap("s1", 1).await.is_none());
858        assert!(client.check_sequence_gap("s1", 5).await.is_some());
859    }
860
861    #[tokio::test]
862    async fn delta_push_includes_crc32c() {
863        let client = SyncClient::new(make_config(), 42);
864        let delta_bytes = vec![1, 2, 3, 4, 5];
865        let expected_crc = crc32c::crc32c(&delta_bytes);
866        let pending = vec![PendingDelta {
867            mutation_id: 1,
868            collection: "test".into(),
869            document_id: "d1".into(),
870            delta_bytes,
871        }];
872        let msgs = client.build_delta_pushes(&pending).await;
873        assert_eq!(msgs[0].checksum, expected_crc);
874        assert_ne!(msgs[0].checksum, 0);
875    }
876
877    #[tokio::test]
878    async fn flow_control_pauses_when_window_full() {
879        let client = SyncClient::with_flow_control(
880            make_config(),
881            1,
882            super::super::flow_control::FlowControlConfig {
883                max_in_flight: 2,
884                initial_batch_size: 10,
885                ..Default::default()
886            },
887        );
888        let pending = vec![
889            PendingDelta {
890                mutation_id: 1,
891                collection: "a".into(),
892                document_id: "d1".into(),
893                delta_bytes: vec![1],
894            },
895            PendingDelta {
896                mutation_id: 2,
897                collection: "a".into(),
898                document_id: "d2".into(),
899                delta_bytes: vec![2],
900            },
901            PendingDelta {
902                mutation_id: 3,
903                collection: "a".into(),
904                document_id: "d3".into(),
905                delta_bytes: vec![3],
906            },
907        ];
908
909        // First batch: window is 2, so only 2 deltas.
910        let msgs = client.build_delta_pushes(&pending).await;
911        assert_eq!(msgs.len(), 2);
912
913        // Record them as pushed.
914        client.record_push(&[1, 2]).await;
915
916        // Window is now full — should return 0.
917        let msgs = client.build_delta_pushes(&pending).await;
918        assert_eq!(msgs.len(), 0);
919
920        // ACK one — window opens by 1.
921        client
922            .handle_delta_ack(&DeltaAckMsg {
923                mutation_id: 1,
924                lsn: 10,
925            })
926            .await;
927        let msgs = client.build_delta_pushes(&pending).await;
928        assert_eq!(msgs.len(), 1);
929    }
930
931    #[tokio::test]
932    async fn sync_metrics_snapshot() {
933        let client = SyncClient::new(make_config(), 1);
934        let snap = client.sync_metrics().await;
935        assert_eq!(snap.state, "disconnected");
936        assert_eq!(snap.pending_count, 0);
937        assert_eq!(snap.deltas_pushed, 0);
938    }
939}