1use 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#[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 pub item_name: Option<String>,
35}
36
37#[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 enqueue_mutation(&*db_service, user_id, operation, item_id, payload).await
53}
54
55pub(crate) async fn enqueue_mutation<S: DatabaseService>(
59 db_service: &S,
60 user_id: String,
61 operation: String,
62 item_id: Option<String>,
63 payload: Option<String>,
64) -> Result<i64, String> {
65 let query = Query::with_params(
66 "INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
67 VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
68 vec![
69 QueryParam::String(user_id),
70 QueryParam::String(operation),
71 item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
72 payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
73 ],
74 );
75
76 db_service.insert(query).await
79}
80
81#[tauri::command]
83#[specta::specta]
84pub async fn sync_get_pending(
85 db: State<'_, DatabaseWrapper>,
86 user_id: String,
87 limit: Option<i32>,
88) -> Result<Vec<SyncQueueItem>, String> {
89 let db_service = {
90 let database = db.0.lock().map_err(|e| e.to_string())?;
91 Arc::new(database.service())
92 };
93
94 const SELECT: &str = "SELECT q.id, q.user_id, q.operation, q.item_id, q.payload, q.status,
99 COALESCE(q.retry_count, 0), q.created_at, q.error_message, i.name
100 FROM sync_queue q
101 LEFT JOIN items i ON i.id = q.item_id
102 WHERE q.user_id = ? AND q.status IN ('pending', 'failed')
103 ORDER BY q.created_at ASC, q.id ASC";
104
105 let sql = match limit {
106 Some(l) => format!("{} LIMIT {}", SELECT, l),
107 None => SELECT.to_string(),
108 };
109
110 let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
111
112 db_service
113 .query_many(query, |row| {
114 Ok(SyncQueueItem {
115 id: row.get(0)?,
116 user_id: row.get(1)?,
117 operation: row.get(2)?,
118 item_id: row.get(3)?,
119 payload: row.get(4)?,
120 status: row.get(5)?,
121 retry_count: row.get(6)?,
122 created_at: row.get(7)?,
123 error_message: row.get(8)?,
124 item_name: row.get(9)?,
125 })
126 })
127 .await
128 .map_err(|e| e.to_string())
129}
130
131#[tauri::command]
133#[specta::specta]
134pub async fn sync_mark_processing(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
135 let db_service = {
136 let database = db.0.lock().map_err(|e| e.to_string())?;
137 Arc::new(database.service())
138 };
139
140 let query = Query::with_params(
141 "UPDATE sync_queue SET status = 'processing' WHERE id = ?",
142 vec![QueryParam::Int64(id)],
143 );
144
145 db_service.execute(query).await.map_err(|e| e.to_string())?;
146 Ok(())
147}
148
149#[tauri::command]
151#[specta::specta]
152pub async fn sync_mark_completed(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
153 let db_service = {
154 let database = db.0.lock().map_err(|e| e.to_string())?;
155 Arc::new(database.service())
156 };
157
158 let query = Query::with_params(
159 "UPDATE sync_queue SET status = 'completed', processed_at = CURRENT_TIMESTAMP WHERE id = ?",
160 vec![QueryParam::Int64(id)],
161 );
162
163 db_service.execute(query).await.map_err(|e| e.to_string())?;
164 Ok(())
165}
166
167#[tauri::command]
169#[specta::specta]
170pub async fn sync_mark_failed(
171 db: State<'_, DatabaseWrapper>,
172 id: i64,
173 error: String,
174) -> Result<(), String> {
175 let db_service = {
176 let database = db.0.lock().map_err(|e| e.to_string())?;
177 Arc::new(database.service())
178 };
179
180 let query = Query::with_params(
181 "UPDATE sync_queue
182 SET status = 'failed',
183 retry_count = retry_count + 1,
184 error_message = ?,
185 processed_at = CURRENT_TIMESTAMP
186 WHERE id = ?",
187 vec![QueryParam::String(error), QueryParam::Int64(id)],
188 );
189
190 db_service.execute(query).await.map_err(|e| e.to_string())?;
191 Ok(())
192}
193
194#[tauri::command]
196#[specta::specta]
197pub async fn sync_get_pending_count(
198 db: State<'_, DatabaseWrapper>,
199 user_id: String,
200) -> Result<i32, String> {
201 let db_service = {
202 let database = db.0.lock().map_err(|e| e.to_string())?;
203 Arc::new(database.service())
204 };
205
206 let query = Query::with_params(
207 "SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
208 vec![QueryParam::String(user_id)],
209 );
210
211 db_service
212 .query_one(query, |row| row.get(0))
213 .await
214 .map_err(|e| e.to_string())
215}
216
217#[tauri::command]
219#[specta::specta]
220pub async fn sync_cleanup_completed(
221 db: State<'_, DatabaseWrapper>,
222 days_old: i32,
223) -> Result<i32, String> {
224 let db_service = {
225 let database = db.0.lock().map_err(|e| e.to_string())?;
226 Arc::new(database.service())
227 };
228
229 let query = Query::with_params(
230 "DELETE FROM sync_queue
231 WHERE status = 'completed'
232 AND processed_at < datetime('now', ?)",
233 vec![QueryParam::String(format!("-{} days", days_old))],
234 );
235
236 let deleted = db_service.execute(query).await.map_err(|e| e.to_string())?;
237 Ok(deleted as i32)
238}
239
240#[tauri::command]
242#[specta::specta]
243pub async fn sync_clear_user(
244 db: State<'_, DatabaseWrapper>,
245 user_id: String,
246) -> Result<(), String> {
247 let db_service = {
248 let database = db.0.lock().map_err(|e| e.to_string())?;
249 Arc::new(database.service())
250 };
251
252 let query = Query::with_params(
253 "DELETE FROM sync_queue WHERE user_id = ?",
254 vec![QueryParam::String(user_id)],
255 );
256
257 db_service.execute(query).await.map_err(|e| e.to_string())?;
258 Ok(())
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn test_sync_queue_item_serialization() {
267 let item = SyncQueueItem {
268 id: 1,
269 user_id: "user-123".to_string(),
270 operation: "favorite".to_string(),
271 item_id: Some("item-456".to_string()),
272 payload: Some(r#"{"isFavorite": true}"#.to_string()),
273 status: "pending".to_string(),
274 retry_count: 0,
275 created_at: Some("2024-02-14T08:00:00Z".to_string()),
276 error_message: None,
277 item_name: None,
278 };
279
280 let json = serde_json::to_string(&item);
282 assert!(json.is_ok());
283
284 let serialized = json.unwrap();
285 assert!(serialized.contains("user-123"));
286 assert!(serialized.contains("favorite"));
287 assert!(serialized.contains("pending"));
288 }
289
290 #[test]
291 fn test_sync_queue_item_with_error() {
292 let item = SyncQueueItem {
293 id: 2,
294 user_id: "user-789".to_string(),
295 operation: "update_progress".to_string(),
296 item_id: Some("item-999".to_string()),
297 payload: None,
298 status: "failed".to_string(),
299 retry_count: 3,
300 created_at: Some("2024-02-14T07:00:00Z".to_string()),
301 error_message: Some("Connection timeout".to_string()),
302 item_name: None,
303 };
304
305 let json = serde_json::to_string(&item).unwrap();
306 assert!(json.contains("failed"));
307 assert!(json.contains("Connection timeout"));
308 assert!(json.contains("3")); }
310
311 #[test]
312 fn test_sync_queue_item_without_optional_fields() {
313 let item = SyncQueueItem {
314 id: 3,
315 user_id: "user-000".to_string(),
316 operation: "clear_progress".to_string(),
317 item_id: None,
318 payload: None,
319 status: "completed".to_string(),
320 retry_count: 0,
321 created_at: None,
322 error_message: None,
323 item_name: None,
324 };
325
326 let json = serde_json::to_string(&item).unwrap();
327 assert!(json.contains("completed"));
328 assert!(json.contains("null") || json.contains("\"itemId\":null"));
329 }
330
331 #[test]
332 fn test_sync_status_values() {
333 let valid_statuses = vec!["pending", "processing", "completed", "failed"];
335
336 for status in valid_statuses {
337 let item = SyncQueueItem {
338 id: 1,
339 user_id: "test".to_string(),
340 operation: "test".to_string(),
341 item_id: None,
342 payload: None,
343 status: status.to_string(),
344 retry_count: 0,
345 created_at: None,
346 error_message: None,
347 item_name: None,
348 };
349
350 let json = serde_json::to_string(&item).unwrap();
351 assert!(json.contains(status));
352 }
353 }
354
355 #[test]
356 fn test_query_param_generation() {
357 let user_id = "user-123".to_string();
359 let operation = "favorite".to_string();
360
361 let params: Vec<QueryParam> = vec![
362 QueryParam::String(user_id.clone()),
363 QueryParam::String(operation.clone()),
364 QueryParam::Null,
365 QueryParam::Null,
366 ];
367
368 assert_eq!(params.len(), 4);
369 assert!(matches!(params[0], QueryParam::String(_)));
370 assert!(matches!(params[1], QueryParam::String(_)));
371 assert!(matches!(params[2], QueryParam::Null));
372 assert!(matches!(params[3], QueryParam::Null));
373 }
374
375 #[test]
376 fn test_retry_count_increment() {
377 let mut item = SyncQueueItem {
379 id: 1,
380 user_id: "user-123".to_string(),
381 operation: "favorite".to_string(),
382 item_id: None,
383 payload: None,
384 status: "pending".to_string(),
385 retry_count: 0,
386 created_at: None,
387 error_message: None,
388 item_name: None,
389 };
390
391 for i in 1..=5 {
393 item.retry_count = i;
394 item.status = if i < 3 { "pending" } else { "failed" }.to_string();
395
396 assert!(item.retry_count == i);
397 }
398 }
399
400 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
409 async fn concurrent_enqueues_each_get_their_own_row_id() {
410 let database = crate::storage::Database::open_in_memory().unwrap();
411 let service = Arc::new(database.service());
412 service
413 .execute(Query::new(
414 "INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s')",
415 ))
416 .await
417 .unwrap();
418 service
419 .execute(Query::new(
420 "INSERT INTO users (id, server_id, username) VALUES ('u', 's', 'u')",
421 ))
422 .await
423 .unwrap();
424
425 let tasks: Vec<_> = (0..200)
426 .map(|i| {
427 let service = Arc::clone(&service);
428 tokio::spawn(async move {
429 let op = format!("op-{i}");
430 let id = enqueue_mutation(&*service, "u".into(), op.clone(), None, None)
431 .await
432 .unwrap();
433 (op, id)
434 })
435 })
436 .collect();
437
438 for task in tasks {
439 let (op, id) = task.await.unwrap();
440 let stored: String = service
441 .query_one(
442 Query::with_params(
443 "SELECT operation FROM sync_queue WHERE id = ?",
444 vec![QueryParam::Int64(id)],
445 ),
446 |row| row.get(0),
447 )
448 .await
449 .unwrap();
450 assert_eq!(stored, op, "mutation {op} was handed row {id}");
451 }
452 }
453}