Skip to main content

#
jellytau_lib/commands/
sync.rs

    1//! Tauri commands for sync queue operations
    2//!
    3//! The sync queue stores mutations (favorites, playback progress, etc.)
    4//! that need to be synced to the Jellyfin server when connectivity is restored.
    5//! Draining it lives in `sync_drain` (DR-131); this module is the storage and
    6//! read side the UI lists from (DR-132).
    7//! TRACES: UR-002, UR-017, UR-025 | DR-014, DR-131, DR-132
    8
    9use serde::{Deserialize, Serialize};
    10use std::sync::Arc;
    11use tauri::State;
    12
    13use super::storage::DatabaseWrapper;
    14use crate::storage::db_service::{DatabaseService, Query, QueryParam};
    15
    16/// Sync queue item returned to frontend
    17#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
    18#[serde(rename_all = "camelCase")]
    19pub struct SyncQueueItem {
    20    pub id: i64,
    21    pub user_id: String,
    22    pub operation: String,
    23    pub item_id: Option<String>,
    24    pub payload: Option<String>,
    25    pub status: String,
    26    pub retry_count: i32,
    27    pub created_at: Option<String>,
    28    pub error_message: Option<String>,
    29    /// Cached title of the item the operation is about, when the catalog knows
    30    /// it. Resolved here rather than by a per-row frontend fetch — the queue
    31    /// list is otherwise a wall of opaque ids.
    32    ///
    33    /// TRACES: UR-025 | DR-132
    34    pub item_name: Option<String>,
    35}
    36
    37/// Queue a mutation for sync to server
    38#[tauri::command]
    39#[specta::specta]
    40pub async fn sync_queue_mutation(
    41    db: State<'_, DatabaseWrapper>,
    42    user_id: String,
    43    operation: String,
    44    item_id: Option<String>,
    45    payload: Option<String>,
    46) -> Result<i64, String> {
    47    let db_service = {
    48        let database = db.0.lock().map_err(|e| e.to_string())?;
    49        Arc::new(database.service())
    50    };
    51
    52    let query = Query::with_params(
    53        "INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
    54         VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
    55        vec![
    56            QueryParam::String(user_id),
    57            QueryParam::String(operation),
    58            item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
    59            payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
    60        ],
    61    );
    62
    63    db_service.execute(query).await.map_err(|e| e.to_string())?;
    64    let id = db_service
    65        .last_insert_rowid()
    66        .await
    67        .map_err(|e| e.to_string())?;
    68
    69    Ok(id)
    70}
    71
    72/// Get all pending sync operations for a user
    73#[tauri::command]
    74#[specta::specta]
    75pub async fn sync_get_pending(
    76    db: State<'_, DatabaseWrapper>,
    77    user_id: String,
    78    limit: Option<i32>,
    79) -> Result<Vec<SyncQueueItem>, String> {
    80    let db_service = {
    81        let database = db.0.lock().map_err(|e| e.to_string())?;
    82        Arc::new(database.service())
    83    };
    84
    85    // The `items` join names the queued item where the catalog has it; a row for
    86    // an item that was never cached still lists, with a null name.
    87    // `abandoned` rows (DR-131 gave up on them) are excluded here for the same
    88    // reason they are excluded from the count — they are no longer waiting.
    89    const SELECT: &str = "SELECT q.id, q.user_id, q.operation, q.item_id, q.payload, q.status,
    90                                 COALESCE(q.retry_count, 0), q.created_at, q.error_message, i.name
    91                          FROM sync_queue q
    92                          LEFT JOIN items i ON i.id = q.item_id
    93                          WHERE q.user_id = ? AND q.status IN ('pending', 'failed')
    94                          ORDER BY q.created_at ASC, q.id ASC";
    95
    96    let sql = match limit {
    97        Some(l) => format!("{} LIMIT {}", SELECT, l),
    98        None => SELECT.to_string(),
    99    };
    100
    101    let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
    102
    103    db_service
    104        .query_many(query, |row| {
    105            Ok(SyncQueueItem {
    106                id: row.get(0)?,
    107                user_id: row.get(1)?,
    108                operation: row.get(2)?,
    109                item_id: row.get(3)?,
    110                payload: row.get(4)?,
    111                status: row.get(5)?,
    112                retry_count: row.get(6)?,
    113                created_at: row.get(7)?,
    114                error_message: row.get(8)?,
    115                item_name: row.get(9)?,
    116            })
    117        })
    118        .await
    119        .map_err(|e| e.to_string())
    120}
    121
    122/// Mark a sync operation as in progress
    123#[tauri::command]
    124#[specta::specta]
    125pub async fn sync_mark_processing(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
    126    let db_service = {
    127        let database = db.0.lock().map_err(|e| e.to_string())?;
    128        Arc::new(database.service())
    129    };
    130
    131    let query = Query::with_params(
    132        "UPDATE sync_queue SET status = 'processing' WHERE id = ?",
    133        vec![QueryParam::Int64(id)],
    134    );
    135
    136    db_service.execute(query).await.map_err(|e| e.to_string())?;
    137    Ok(())
    138}
    139
    140/// Mark a sync operation as completed
    141#[tauri::command]
    142#[specta::specta]
    143pub async fn sync_mark_completed(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
    144    let db_service = {
    145        let database = db.0.lock().map_err(|e| e.to_string())?;
    146        Arc::new(database.service())
    147    };
    148
    149    let query = Query::with_params(
    150        "UPDATE sync_queue SET status = 'completed', processed_at = CURRENT_TIMESTAMP WHERE id = ?",
    151        vec![QueryParam::Int64(id)],
    152    );
    153
    154    db_service.execute(query).await.map_err(|e| e.to_string())?;
    155    Ok(())
    156}
    157
    158/// Mark a sync operation as failed with error message
    159#[tauri::command]
    160#[specta::specta]
    161pub async fn sync_mark_failed(
    162    db: State<'_, DatabaseWrapper>,
    163    id: i64,
    164    error: String,
    165) -> Result<(), String> {
    166    let db_service = {
    167        let database = db.0.lock().map_err(|e| e.to_string())?;
    168        Arc::new(database.service())
    169    };
    170
    171    let query = Query::with_params(
    172        "UPDATE sync_queue
    173         SET status = 'failed',
    174             retry_count = retry_count + 1,
    175             error_message = ?,
    176             processed_at = CURRENT_TIMESTAMP
    177         WHERE id = ?",
    178        vec![QueryParam::String(error), QueryParam::Int64(id)],
    179    );
    180
    181    db_service.execute(query).await.map_err(|e| e.to_string())?;
    182    Ok(())
    183}
    184
    185/// Get count of pending sync operations for a user
    186#[tauri::command]
    187#[specta::specta]
    188pub async fn sync_get_pending_count(
    189    db: State<'_, DatabaseWrapper>,
    190    user_id: String,
    191) -> Result<i32, String> {
    192    let db_service = {
    193        let database = db.0.lock().map_err(|e| e.to_string())?;
    194        Arc::new(database.service())
    195    };
    196
    197    let query = Query::with_params(
    198        "SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
    199        vec![QueryParam::String(user_id)],
    200    );
    201
    202    db_service
    203        .query_one(query, |row| row.get(0))
    204        .await
    205        .map_err(|e| e.to_string())
    206}
    207
    208/// Delete completed sync operations older than specified days
    209#[tauri::command]
    210#[specta::specta]
    211pub async fn sync_cleanup_completed(
    212    db: State<'_, DatabaseWrapper>,
    213    days_old: i32,
    214) -> Result<i32, String> {
    215    let db_service = {
    216        let database = db.0.lock().map_err(|e| e.to_string())?;
    217        Arc::new(database.service())
    218    };
    219
    220    let query = Query::with_params(
    221        "DELETE FROM sync_queue
    222         WHERE status = 'completed'
    223         AND processed_at < datetime('now', ?)",
    224        vec![QueryParam::String(format!("-{} days", days_old))],
    225    );
    226
    227    let deleted = db_service.execute(query).await.map_err(|e| e.to_string())?;
    228    Ok(deleted as i32)
    229}
    230
    231/// Delete all sync operations for a user (used during logout)
    232#[tauri::command]
    233#[specta::specta]
    234pub async fn sync_clear_user(
    235    db: State<'_, DatabaseWrapper>,
    236    user_id: String,
    237) -> Result<(), String> {
    238    let db_service = {
    239        let database = db.0.lock().map_err(|e| e.to_string())?;
    240        Arc::new(database.service())
    241    };
    242
    243    let query = Query::with_params(
    244        "DELETE FROM sync_queue WHERE user_id = ?",
    245        vec![QueryParam::String(user_id)],
    246    );
    247
    248    db_service.execute(query).await.map_err(|e| e.to_string())?;
    249    Ok(())
    250}
    251
    252#[cfg(test)]
    253mod tests {
    254    use super::*;
    255
    256    #[test]
    257    fn test_sync_queue_item_serialization() {
    258        let item = SyncQueueItem {
    259            id: 1,
    260            user_id: "user-123".to_string(),
    261            operation: "favorite".to_string(),
    262            item_id: Some("item-456".to_string()),
    263            payload: Some(r#"{"isFavorite": true}"#.to_string()),
    264            status: "pending".to_string(),
    265            retry_count: 0,
    266            created_at: Some("2024-02-14T08:00:00Z".to_string()),
    267            error_message: None,
    268            item_name: None,
    269        };
    270
    271        // Should serialize successfully
    272        let json = serde_json::to_string(&item);
    273        assert!(json.is_ok());
    274
    275        let serialized = json.unwrap();
    276        assert!(serialized.contains("user-123"));
    277        assert!(serialized.contains("favorite"));
    278        assert!(serialized.contains("pending"));
    279    }
    280
    281    #[test]
    282    fn test_sync_queue_item_with_error() {
    283        let item = SyncQueueItem {
    284            id: 2,
    285            user_id: "user-789".to_string(),
    286            operation: "update_progress".to_string(),
    287            item_id: Some("item-999".to_string()),
    288            payload: None,
    289            status: "failed".to_string(),
    290            retry_count: 3,
    291            created_at: Some("2024-02-14T07:00:00Z".to_string()),
    292            error_message: Some("Connection timeout".to_string()),
    293            item_name: None,
    294        };
    295
    296        let json = serde_json::to_string(&item).unwrap();
    297        assert!(json.contains("failed"));
    298        assert!(json.contains("Connection timeout"));
    299        assert!(json.contains("3")); // retry_count
    300    }
    301
    302    #[test]
    303    fn test_sync_queue_item_without_optional_fields() {
    304        let item = SyncQueueItem {
    305            id: 3,
    306            user_id: "user-000".to_string(),
    307            operation: "clear_progress".to_string(),
    308            item_id: None,
    309            payload: None,
    310            status: "completed".to_string(),
    311            retry_count: 0,
    312            created_at: None,
    313            error_message: None,
    314            item_name: None,
    315        };
    316
    317        let json = serde_json::to_string(&item).unwrap();
    318        assert!(json.contains("completed"));
    319        assert!(json.contains("null") || json.contains("\"itemId\":null"));
    320    }
    321
    322    #[test]
    323    fn test_sync_status_values() {
    324        // Verify all expected status values
    325        let valid_statuses = vec!["pending", "processing", "completed", "failed"];
    326
    327        for status in valid_statuses {
    328            let item = SyncQueueItem {
    329                id: 1,
    330                user_id: "test".to_string(),
    331                operation: "test".to_string(),
    332                item_id: None,
    333                payload: None,
    334                status: status.to_string(),
    335                retry_count: 0,
    336                created_at: None,
    337                error_message: None,
    338                item_name: None,
    339            };
    340
    341            let json = serde_json::to_string(&item).unwrap();
    342            assert!(json.contains(status));
    343        }
    344    }
    345
    346    #[test]
    347    fn test_query_param_generation() {
    348        // Test QueryParam generation for sync operations
    349        let user_id = "user-123".to_string();
    350        let operation = "favorite".to_string();
    351
    352        let params: Vec<QueryParam> = vec![
    353            QueryParam::String(user_id.clone()),
    354            QueryParam::String(operation.clone()),
    355            QueryParam::Null,
    356            QueryParam::Null,
    357        ];
    358
    359        assert_eq!(params.len(), 4);
    360        assert!(matches!(params[0], QueryParam::String(_)));
    361        assert!(matches!(params[1], QueryParam::String(_)));
    362        assert!(matches!(params[2], QueryParam::Null));
    363        assert!(matches!(params[3], QueryParam::Null));
    364    }
    365
    366    #[test]
    367    fn test_retry_count_increment() {
    368        // Verify retry count management
    369        let mut item = SyncQueueItem {
    370            id: 1,
    371            user_id: "user-123".to_string(),
    372            operation: "favorite".to_string(),
    373            item_id: None,
    374            payload: None,
    375            status: "pending".to_string(),
    376            retry_count: 0,
    377            created_at: None,
    378            error_message: None,
    379            item_name: None,
    380        };
    381
    382        // Simulate retries
    383        for i in 1..=5 {
    384            item.retry_count = i;
    385            item.status = if i < 3 { "pending" } else { "failed" }.to_string();
    386
    387            assert!(item.retry_count == i);
    388        }
    389    }
    390}