//! Background scheduler support: the observer interface the engine reports //! through, and the small pure decisions the scheduler loop makes (backoff, //! interval-elapsed, subscription-required classification). //! //! The loop itself lives on [`SyncStore`](super::facade::SyncStore) (it needs the //! concrete client's SSE stream and auth state); the pieces here are factored out //! so they can be unit-tested in isolation. use std::collections::HashSet; use std::time::Duration; use chrono::{DateTime, Utc}; use crate::error::SyncKitError; /// Coarse sync state reported to the UI. (Named to avoid colliding with the /// server-facing [`SyncStatus`](crate::types::SyncStatus).) #[derive(Debug, Clone, PartialEq, Eq)] pub enum SyncState { /// Not currently syncing; last cycle succeeded (or none has run). Idle, /// A sync cycle is in progress. Syncing, /// The last cycle failed; carries a human-readable reason. Error(String), /// Sync is blocked pending an active membership/purchase (server `402`). SubscriptionRequired, /// The session is gone (token expired / cleared); re-auth needed. LoggedOut, } /// How the engine reports progress. All methods default to no-ops, so an app /// implements only the signals it renders. Bridges any UI model (Tauri events, /// an `Arc>`, a channel). pub trait SyncObserver: Send + Sync { /// The coarse state changed. fn on_status(&self, _state: SyncState) {} /// A pull applied changes touching these tables (for selective invalidation). fn on_changes_applied(&self, _tables: &HashSet) {} /// The server refused sync pending a subscription. fn on_subscription_required(&self) {} } /// An observer that discards every signal. #[derive(Debug, Default, Clone, Copy)] pub struct NoopObserver; impl SyncObserver for NoopObserver {} /// Exponential backoff after `consecutive_failures` failed cycles: 1, 2, 4, 8, /// then capped at 15 minutes. `0` failures means "no backoff" is not this, the /// first failure (count 1) waits 2 minutes; call with the post-increment count. pub fn backoff_delay(consecutive_failures: u32) -> Duration { let mins = 2u64.pow(consecutive_failures.min(4)).min(15); Duration::from_secs(mins * 60) } /// Whether enough time has elapsed since `last_sync` to sync again on the timer /// path. A missing/unparseable `last_sync` is always due. pub fn interval_elapsed( last_sync: Option>, interval_minutes: i64, now: DateTime, ) -> bool { match last_sync { None => true, Some(prev) => now.signed_duration_since(prev).num_minutes() >= interval_minutes, } } /// Whether an error means the server is gating sync behind a subscription (`402`). pub fn is_subscription_required(err: &SyncKitError) -> bool { matches!(err, SyncKitError::Server { status: 402, .. }) } /// Whether an error is a lost/expired session the caller should surface as /// logged-out (`401`/`403`, or the client's own token-expiry signal). pub fn is_auth_lost(err: &SyncKitError) -> bool { matches!( err, SyncKitError::TokenExpired | SyncKitError::NotAuthenticated ) || matches!( err, SyncKitError::Server { status: 401 | 403, .. } ) } #[cfg(test)] mod tests { use super::*; #[test] fn backoff_is_exponential_then_capped() { assert_eq!(backoff_delay(0).as_secs(), 60); // 2^0 = 1 minute assert_eq!(backoff_delay(1).as_secs(), 2 * 60); assert_eq!(backoff_delay(2).as_secs(), 4 * 60); assert_eq!(backoff_delay(3).as_secs(), 8 * 60); assert_eq!(backoff_delay(4).as_secs(), 15 * 60); // 2^4=16 capped to 15 assert_eq!(backoff_delay(50).as_secs(), 15 * 60); // no overflow, still capped } #[test] fn interval_elapsed_rules() { let now = Utc::now(); assert!( interval_elapsed(None, 15, now), "never-synced is always due" ); let five_ago = now - chrono::Duration::minutes(5); assert!(!interval_elapsed(Some(five_ago), 15, now), "too soon"); let twenty_ago = now - chrono::Duration::minutes(20); assert!( interval_elapsed(Some(twenty_ago), 15, now), "past the interval" ); } #[test] fn error_classification() { let sub = SyncKitError::Server { status: 402, message: "pay".into(), retry_after_secs: None, }; assert!(is_subscription_required(&sub)); assert!(!is_auth_lost(&sub)); assert!(is_auth_lost(&SyncKitError::TokenExpired)); assert!(is_auth_lost(&SyncKitError::Server { status: 401, message: String::new(), retry_after_secs: None })); assert!(!is_subscription_required(&SyncKitError::TokenExpired)); let other = SyncKitError::Internal("boom".into()); assert!(!is_subscription_required(&other) && !is_auth_lost(&other)); } }