Skip to main content

#
jellytau_lib/commands/
favorites.rs

    1//! Pushing favourite toggles made while the server was unreachable.
    2//!
    3//! Favouriting works offline: `storage_toggle_favorite` writes the local
    4//! `user_data` row and sets `pending_sync = 1`. Until DR-120 nothing ever
    5//! cleared that flag — the offline `mark_favorite`/`unmark_favorite` are no-ops
    6//! and `syncService.queueFavorite` had no callers — so an offline toggle was
    7//! silently lost.
    8//!
    9//! The drain lives in Rust, not the frontend, because it must run whether or
    10//! not any view is mounted; a drain started by a component dies with it.
    11//!
    12//! TRACES: UR-069 | DR-120 | UT-103
    13
    14use std::sync::Arc;
    15
    16use async_trait::async_trait;
    17use log::{debug, info, warn};
    18use tauri::{Emitter, Listener, Manager};
    19
    20use crate::repository::types::RepoError;
    21use crate::repository::MediaRepository;
    22use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
    23
    24/// The subset of the repository the drain needs.
    25///
    26/// Narrow on purpose: a test double for `MediaRepository` would be forty
    27/// unimplemented methods, which is how a drain ends up untested.
    28#[async_trait]
    29pub trait FavoriteSink: Send + Sync {
    30    async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError>;
    31}
    32
    33#[async_trait]
    34impl<T: MediaRepository + ?Sized> FavoriteSink for T {
    35    async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
    36        if is_favorite {
    37            self.mark_favorite(item_id).await
    38        } else {
    39            self.unmark_favorite(item_id).await
    40        }
    41    }
    42}
    43
    44/// A local favourite change still waiting to reach the server.
    45#[derive(Debug, Clone, PartialEq, Eq)]
    46pub struct PendingFavorite {
    47    pub item_id: String,
    48    pub is_favorite: bool,
    49}
    50
    51/// Read every favourite change this user has pending.
    52async fn read_pending(
    53    db: &Arc<RusqliteService>,
    54    user_id: &str,
    55) -> Result<Vec<PendingFavorite>, String> {
    56    db.query_many(
    57        Query::with_params(
    58            "SELECT item_id, is_favorite FROM user_data \
    59             WHERE user_id = ? AND pending_sync = 1 AND is_favorite IS NOT NULL",
    60            vec![QueryParam::String(user_id.to_string())],
    61        ),
    62        |row| {
    63            Ok(PendingFavorite {
    64                item_id: row.get::<_, String>(0)?,
    65                is_favorite: row.get::<_, Option<i32>>(1)?.unwrap_or(0) != 0,
    66            })
    67        },
    68    )
    69    .await
    70}
    71
    72/// Push pending favourite changes to the server and clear their flags.
    73///
    74/// Returns the ids that reached the server, for the `favorites-changed` event.
    75/// A row whose push fails keeps `pending_sync = 1` and is retried on the next
    76/// reconnect rather than being dropped.
    77///
    78/// TRACES: UR-069 | DR-120 | UT-103
    79pub async fn drain_pending_favorites(
    80    db: &Arc<RusqliteService>,
    81    sink: &dyn FavoriteSink,
    82    user_id: &str,
    83) -> Result<Vec<String>, String> {
    84    let pending = read_pending(db, user_id).await?;
    85    if pending.is_empty() {
    86        return Ok(Vec::new());
    87    }
    88
    89    info!(
    90        "[Favorites] Pushing {} favourite change(s) queued while offline",
    91        pending.len()
    92    );
    93
    94    let mut pushed = Vec::new();
    95    for change in pending {
    96        match sink
    97            .push_favorite(&change.item_id, change.is_favorite)
    98            .await
    99        {
    100            Ok(()) => {
    101                let cleared = db
    102                    .execute(Query::with_params(
    103                        "UPDATE user_data SET pending_sync = 0, synced_at = ? \
    104                         WHERE user_id = ? AND item_id = ?",
    105                        vec![
    106                            QueryParam::String(chrono::Utc::now().to_rfc3339()),
    107                            QueryParam::String(user_id.to_string()),
    108                            QueryParam::String(change.item_id.clone()),
    109                        ],
    110                    ))
    111                    .await;
    112
    113                match cleared {
    114                    Ok(_) => pushed.push(change.item_id),
    115                    // The server took it; failing to clear the flag only means
    116                    // we push it again next time, which is harmless.
    117                    Err(e) => warn!(
    118                        "[Favorites] Pushed {} but could not clear pending_sync: {}",
    119                        change.item_id, e
    120                    ),
    121                }
    122            }
    123            Err(e) => {
    124                // Still pending — retried on the next reconnect.
    125                debug!(
    126                    "[Favorites] Deferring {}, server rejected the push: {:?}",
    127                    change.item_id, e
    128                );
    129            }
    130        }
    131    }
    132
    133    Ok(pushed)
    134}
    135
    136/// Drain on every offline→online transition.
    137///
    138/// Hooks the `connectivity:reconnected` event the `ConnectivityMonitor`
    139/// already emits, rather than polling — reachability is derived from real
    140/// traffic (DR-055) and this just reacts to it.
    141///
    142/// TRACES: UR-069 | DR-120
    143pub fn spawn_favorites_drain(app: tauri::AppHandle) {
    144    let handle = app.clone();
    145    app.listen("connectivity:reconnected", move |_event| {
    146        let app = handle.clone();
    147        tauri::async_runtime::spawn(async move {
    148            if let Err(e) = run_drain(&app).await {
    149                warn!("[Favorites] Drain skipped: {}", e);
    150            }
    151        });
    152    });
    153}
    154
    155async fn run_drain(app: &tauri::AppHandle) -> Result<(), String> {
    156    let db_service: Arc<RusqliteService> = {
    157        let db = app.state::<crate::commands::storage::DatabaseWrapper>();
    158        let database = db.0.lock().map_err(|e| e.to_string())?;
    159        Arc::new(database.service())
    160    };
    161
    162    let (repo, user_id) = {
    163        let manager = app.state::<crate::commands::repository::RepositoryManagerWrapper>();
    164        let handles = manager.0.handles();
    165        let Some(handle) = handles.first() else {
    166            // Not signed in — nothing to push on behalf of.
    167            return Ok(());
    168        };
    169        let repo = manager.0.get(handle).ok_or("Repository not found")?;
    170        let user_id = repo.user_id().to_string();
    171        (repo, user_id)
    172    };
    173
    174    let pushed = drain_pending_favorites(&db_service, repo.as_ref(), &user_id).await?;
    175
    176    if !pushed.is_empty() {
    177        let event = crate::commands::repository::FavoritesChangedEvent { item_ids: pushed };
    178        if let Err(e) = app.emit(crate::commands::repository::FAVORITES_CHANGED_EVENT, &event) {
    179            warn!("[Favorites] Failed to emit change event: {}", e);
    180        }
    181    }
    182
    183    Ok(())
    184}
    185
    186#[cfg(test)]
    187mod tests {
    188    use super::*;
    189    use crate::utils::lock::MutexSafe;
    190    use rusqlite::Connection;
    191    use std::sync::Mutex;
    192
    193    /// Records what the server was asked to do, and can be told to fail.
    194    struct RecordingSink {
    195        calls: Mutex<Vec<(String, bool)>>,
    196        fail_for: Option<String>,
    197    }
    198
    199    impl RecordingSink {
    200        fn new() -> Self {
    201            Self {
    202                calls: Mutex::new(Vec::new()),
    203                fail_for: None,
    204            }
    205        }
    206
    207        fn failing_for(item_id: &str) -> Self {
    208            Self {
    209                calls: Mutex::new(Vec::new()),
    210                fail_for: Some(item_id.to_string()),
    211            }
    212        }
    213
    214        fn calls(&self) -> Vec<(String, bool)> {
    215            let mut calls = self.calls.lock_safe().clone();
    216            calls.sort();
    217            calls
    218        }
    219    }
    220
    221    #[async_trait]
    222    impl FavoriteSink for RecordingSink {
    223        async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
    224            if self.fail_for.as_deref() == Some(item_id) {
    225                return Err(RepoError::Offline);
    226            }
    227            self.calls
    228                .lock()
    229                .unwrap()
    230                .push((item_id.to_string(), is_favorite));
    231            Ok(())
    232        }
    233    }
    234
    235    fn test_db() -> Arc<RusqliteService> {
    236        let conn = Connection::open_in_memory().unwrap();
    237        conn.execute_batch(
    238            r#"
    239            CREATE TABLE user_data (
    240                user_id TEXT NOT NULL,
    241                item_id TEXT NOT NULL,
    242                is_favorite INTEGER,
    243                synced_at TEXT,
    244                pending_sync INTEGER DEFAULT 0,
    245                PRIMARY KEY (user_id, item_id)
    246            );
    247            "#,
    248        )
    249        .unwrap();
    250        Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
    251    }
    252
    253    async fn seed(db: &Arc<RusqliteService>, rows: &[(&str, &str, i32, i32)]) {
    254        for (user, item, fav, pending) in rows {
    255            db.execute(Query::with_params(
    256                "INSERT INTO user_data (user_id, item_id, is_favorite, pending_sync) \
    257                 VALUES (?, ?, ?, ?)",
    258                vec![
    259                    QueryParam::String(user.to_string()),
    260                    QueryParam::String(item.to_string()),
    261                    QueryParam::Int(*fav),
    262                    QueryParam::Int(*pending),
    263                ],
    264            ))
    265            .await
    266            .unwrap();
    267        }
    268    }
    269
    270    async fn pending_flag(db: &Arc<RusqliteService>, item_id: &str) -> Option<i32> {
    271        db.query_optional(
    272            Query::with_params(
    273                "SELECT pending_sync FROM user_data WHERE item_id = ?",
    274                vec![QueryParam::String(item_id.to_string())],
    275            ),
    276            |row| row.get::<_, Option<i32>>(0),
    277        )
    278        .await
    279        .unwrap()
    280        .flatten()
    281    }
    282
    283    /// UT-103 — the core of the bug: a favourite toggled while offline reaches
    284    /// the server on reconnect, and stops being pending.
    285    ///
    286    /// TRACES: UR-069 | DR-120 | UT-103
    287    #[tokio::test]
    288    async fn test_drain_pushes_pending_favorites_and_clears_the_flag() {
    289        let db = test_db();
    290        seed(
    291            &db,
    292            &[
    293                ("u1", "marked-offline", 1, 1),
    294                ("u1", "unmarked-offline", 0, 1),
    295                // Already synced — must not be pushed again.
    296                ("u1", "already-synced", 1, 0),
    297            ],
    298        )
    299        .await;
    300
    301        let sink = RecordingSink::new();
    302        let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
    303
    304        assert_eq!(
    305            sink.calls(),
    306            vec![
    307                ("marked-offline".to_string(), true),
    308                ("unmarked-offline".to_string(), false),
    309            ],
    310            "both pending changes push, with their direction preserved"
    311        );
    312        assert_eq!(pushed.len(), 2);
    313        assert_eq!(pending_flag(&db, "marked-offline").await, Some(0));
    314        assert_eq!(pending_flag(&db, "unmarked-offline").await, Some(0));
    315    }
    316
    317    /// A push that fails keeps its row pending, so the change is retried rather
    318    /// than dropped on the floor.
    319    ///
    320    /// TRACES: UR-069 | DR-120 | UT-103
    321    #[tokio::test]
    322    async fn test_drain_leaves_failed_pushes_pending() {
    323        let db = test_db();
    324        seed(&db, &[("u1", "ok", 1, 1), ("u1", "boom", 1, 1)]).await;
    325
    326        let sink = RecordingSink::failing_for("boom");
    327        let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
    328
    329        assert_eq!(pushed, vec!["ok".to_string()]);
    330        assert_eq!(pending_flag(&db, "ok").await, Some(0));
    331        assert_eq!(
    332            pending_flag(&db, "boom").await,
    333            Some(1),
    334            "a failed push must stay queued for the next reconnect"
    335        );
    336    }
    337
    338    /// Another user's queued changes are not pushed with this user's token.
    339    ///
    340    /// TRACES: UR-069 | DR-120 | UT-103
    341    #[tokio::test]
    342    async fn test_drain_only_touches_the_given_user() {
    343        let db = test_db();
    344        seed(&db, &[("u1", "mine", 1, 1), ("u2", "theirs", 1, 1)]).await;
    345
    346        let sink = RecordingSink::new();
    347        let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
    348
    349        assert_eq!(pushed, vec!["mine".to_string()]);
    350        assert_eq!(pending_flag(&db, "theirs").await, Some(1));
    351    }
    352
    353    /// Nothing pending means no server calls at all — a reconnect must not
    354    /// generate traffic just because it happened.
    355    ///
    356    /// TRACES: UR-069 | DR-120 | UT-103
    357    #[tokio::test]
    358    async fn test_drain_is_a_noop_when_nothing_is_pending() {
    359        let db = test_db();
    360        seed(&db, &[("u1", "synced", 1, 0)]).await;
    361
    362        let sink = RecordingSink::new();
    363        let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
    364
    365        assert!(pushed.is_empty());
    366        assert!(sink.calls().is_empty());
    367    }
    368}