Skip to main content

linear_motion/db/
status.rs

1use crate::Result;
2use fjall::{Keyspace, PartitionHandle, PersistMode};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5use tracing::{debug, info, warn};
6use uuid::Uuid;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub enum SyncStatus {
10    InProgress,
11    Completed,
12    Failed,
13    Paused,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SyncStatusEntry {
18    pub id: String,
19    pub sync_source: String,
20    pub linear_issue_id: String,
21    pub motion_task_id: Option<String>,
22    pub status: SyncStatus,
23    pub last_sync_attempt: chrono::DateTime<chrono::Utc>,
24    pub error_message: Option<String>,
25    pub retry_count: u32,
26    pub created_at: chrono::DateTime<chrono::Utc>,
27    pub updated_at: chrono::DateTime<chrono::Utc>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SyncSourceStatus {
32    pub source_name: String,
33    pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
34    pub total_issues_processed: u64,
35    pub successful_syncs: u64,
36    pub failed_syncs: u64,
37    pub errors: Vec<String>,
38}
39
40pub struct StatusStore {
41    keyspace: Keyspace,
42    statuses: PartitionHandle,
43    source_stats: PartitionHandle,
44}
45
46impl StatusStore {
47    pub async fn new<P: AsRef<Path>>(db_path: P) -> Result<Self> {
48        let keyspace = fjall::Config::new(db_path).open()?;
49        let statuses =
50            keyspace.open_partition("sync_statuses", fjall::PartitionCreateOptions::default())?;
51        let source_stats =
52            keyspace.open_partition("source_stats", fjall::PartitionCreateOptions::default())?;
53
54        debug!("status tracking store initialized");
55
56        Ok(Self {
57            keyspace,
58            statuses,
59            source_stats,
60        })
61    }
62
63    pub async fn create_status_entry(
64        &self,
65        sync_source: String,
66        linear_issue_id: String,
67    ) -> Result<SyncStatusEntry> {
68        let entry = SyncStatusEntry::new(sync_source, linear_issue_id);
69        self.store_status_entry(&entry).await?;
70        Ok(entry)
71    }
72
73    pub async fn store_status_entry(&self, entry: &SyncStatusEntry) -> Result<()> {
74        let key = &entry.id;
75        let value = serde_json::to_vec(entry)?;
76
77        self.statuses.insert(key, &value)?;
78        debug!(
79            "Stored status entry: {} ({})",
80            entry.id, entry.linear_issue_id
81        );
82
83        Ok(())
84    }
85
86    pub async fn get_status_entry(&self, id: &str) -> Result<Option<SyncStatusEntry>> {
87        match self.statuses.get(id)? {
88            Some(value) => {
89                let entry: SyncStatusEntry = serde_json::from_slice(&value)?;
90                Ok(Some(entry))
91            }
92            None => Ok(None),
93        }
94    }
95
96    pub async fn update_status(
97        &self,
98        id: &str,
99        status: SyncStatus,
100        error_message: Option<String>,
101    ) -> Result<()> {
102        if let Some(mut entry) = self.get_status_entry(id).await? {
103            entry.status = status;
104            entry.error_message = error_message.clone();
105            entry.last_sync_attempt = chrono::Utc::now();
106            entry.updated_at = chrono::Utc::now();
107
108            if error_message.is_some() {
109                entry.retry_count += 1;
110            }
111
112            self.store_status_entry(&entry).await?;
113            debug!("Updated status for {}: {:?}", id, entry.status);
114        } else {
115            warn!("Attempted to update non-existent status entry: {}", id);
116        }
117
118        Ok(())
119    }
120
121    pub async fn mark_completed(&self, id: &str, motion_task_id: String) -> Result<()> {
122        if let Some(mut entry) = self.get_status_entry(id).await? {
123            entry.status = SyncStatus::Completed;
124            entry.motion_task_id = Some(motion_task_id);
125            entry.last_sync_attempt = chrono::Utc::now();
126            entry.updated_at = chrono::Utc::now();
127            entry.error_message = None;
128
129            self.store_status_entry(&entry).await?;
130            debug!("Marked {} as completed", id);
131        }
132
133        Ok(())
134    }
135
136    pub async fn mark_failed(&self, id: &str, error: String) -> Result<()> {
137        self.update_status(id, SyncStatus::Failed, Some(error))
138            .await
139    }
140
141    pub async fn list_statuses_by_source(&self, sync_source: &str) -> Result<Vec<SyncStatusEntry>> {
142        let mut entries = Vec::new();
143
144        for item in self.statuses.iter() {
145            let (_, value) = item?;
146            let entry: SyncStatusEntry = serde_json::from_slice(&value)?;
147
148            if entry.sync_source == sync_source {
149                entries.push(entry);
150            }
151        }
152
153        debug!(
154            "Found {} status entries for source: {}",
155            entries.len(),
156            sync_source
157        );
158        Ok(entries)
159    }
160
161    pub async fn list_failed_entries(&self) -> Result<Vec<SyncStatusEntry>> {
162        let mut failed_entries = Vec::new();
163
164        for item in self.statuses.iter() {
165            let (_, value) = item?;
166            let entry: SyncStatusEntry = serde_json::from_slice(&value)?;
167
168            if matches!(entry.status, SyncStatus::Failed) {
169                failed_entries.push(entry);
170            }
171        }
172
173        debug!("Found {} failed status entries", failed_entries.len());
174        Ok(failed_entries)
175    }
176
177    pub async fn get_source_status(&self, source_name: &str) -> Result<Option<SyncSourceStatus>> {
178        match self.source_stats.get(source_name)? {
179            Some(value) => {
180                let status: SyncSourceStatus = serde_json::from_slice(&value)?;
181                Ok(Some(status))
182            }
183            None => Ok(None),
184        }
185    }
186
187    pub async fn update_source_stats(
188        &self,
189        source_name: &str,
190        success: bool,
191        error: Option<String>,
192    ) -> Result<()> {
193        let mut status = self
194            .get_source_status(source_name)
195            .await?
196            .unwrap_or_else(|| SyncSourceStatus::new(source_name.to_string()));
197
198        status.last_sync = Some(chrono::Utc::now());
199        status.total_issues_processed += 1;
200
201        if success {
202            status.successful_syncs += 1;
203        } else {
204            status.failed_syncs += 1;
205            if let Some(err) = error {
206                status.errors.push(err);
207                // Keep only the last 10 errors
208                if status.errors.len() > 10 {
209                    status.errors.remove(0);
210                }
211            }
212        }
213
214        let value = serde_json::to_vec(&status)?;
215        self.source_stats.insert(source_name, &value)?;
216
217        debug!(
218            "Updated stats for source {}: {} successful, {} failed",
219            source_name, status.successful_syncs, status.failed_syncs
220        );
221
222        Ok(())
223    }
224
225    pub async fn list_all_source_stats(&self) -> Result<Vec<SyncSourceStatus>> {
226        let mut stats = Vec::new();
227
228        for item in self.source_stats.iter() {
229            let (_, value) = item?;
230            let status: SyncSourceStatus = serde_json::from_slice(&value)?;
231            stats.push(status);
232        }
233
234        Ok(stats)
235    }
236
237    pub async fn cleanup_old_entries(&self, older_than_days: u64) -> Result<u64> {
238        let cutoff = chrono::Utc::now() - chrono::Duration::days(older_than_days as i64);
239        let mut deleted_count = 0;
240
241        let mut keys_to_delete = Vec::new();
242
243        for item in self.statuses.iter() {
244            let (key, value) = item?;
245            let entry: SyncStatusEntry = serde_json::from_slice(&value)?;
246
247            if entry.created_at < cutoff && matches!(entry.status, SyncStatus::Completed) {
248                keys_to_delete.push(key.to_vec());
249            }
250        }
251
252        for key in keys_to_delete {
253            self.statuses.remove(&key)?;
254            deleted_count += 1;
255        }
256
257        if deleted_count > 0 {
258            info!("Cleaned up {} old status entries", deleted_count);
259        }
260
261        Ok(deleted_count)
262    }
263
264    pub async fn flush(&self) -> Result<()> {
265        self.keyspace.persist(PersistMode::SyncAll)?;
266        Ok(())
267    }
268}
269
270impl SyncStatusEntry {
271    pub fn new(sync_source: String, linear_issue_id: String) -> Self {
272        let now = chrono::Utc::now();
273
274        Self {
275            id: Uuid::new_v4().to_string(),
276            sync_source,
277            linear_issue_id,
278            motion_task_id: None,
279            status: SyncStatus::InProgress,
280            last_sync_attempt: now,
281            error_message: None,
282            retry_count: 0,
283            created_at: now,
284            updated_at: now,
285        }
286    }
287}
288
289impl SyncSourceStatus {
290    pub fn new(source_name: String) -> Self {
291        Self {
292            source_name,
293            last_sync: None,
294            total_issues_processed: 0,
295            successful_syncs: 0,
296            failed_syncs: 0,
297            errors: Vec::new(),
298        }
299    }
300}