max / goingson
- Co-Authored-By
- Claude Opus 4.6 (1M context) <noreply@anthropic.com>
28 files changed,
+476 insertions,
-184 deletions
| @@ -47,6 +47,10 @@ | |||
| 47 | 47 | pub sync_accounts: Arc<dyn SyncAccountRepository>, | |
| 48 | 48 | pub sync_client: RwLock<Option<Arc<SyncKitClient>>>, | |
| 49 | 49 | pub sync_lock: Arc<TokioMutex<()>>, | |
| 50 | + | /// Per-account email sync locks to prevent concurrent syncs on the same account. | |
| 51 | + | pub email_sync_locks: Arc<parking_lot::Mutex<std::collections::HashSet<goingson_core::EmailAccountId>>>, | |
| 52 | + | /// Per-account token refresh locks to prevent concurrent refreshes. | |
| 53 | + | pub token_refresh_locks: Arc<parking_lot::Mutex<std::collections::HashMap<uuid::Uuid, Arc<TokioMutex<()>>>>>, | |
| 50 | 54 | pub data_dir: PathBuf, | |
| 51 | 55 | } | |
| 52 | 56 | ||
| @@ -66,12 +70,11 @@ | |||
| 66 | 70 | .map_err(|e| format!("Failed to create app data dir: {}", e))?; | |
| 67 | 71 | ||
| 68 | 72 | let db_path = app_data_dir.join("goingson.db"); | |
| 69 | - | let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); | |
| 70 | 73 | ||
| 71 | 74 | debug!(?db_path, "Connecting to database"); | |
| 72 | 75 | ||
| 73 | - | // Create database connection pool | |
| 74 | - | let pool = SqlitePool::connect(&db_url) | |
| 76 | + | // Create database connection pool (WAL mode, FK enforcement, pool limits) | |
| 77 | + | let pool = goingson_db_sqlite::init_pool(Some(db_path.to_str().unwrap_or("goingson.db"))) | |
| 75 | 78 | .await | |
| 76 | 79 | .map_err(|e| format!("Failed to connect to database: {}", e))?; | |
| 77 | 80 | ||
| @@ -141,9 +144,19 @@ | |||
| 141 | 144 | sync_accounts, | |
| 142 | 145 | sync_client: RwLock::new(sync_client.map(Arc::new)), | |
| 143 | 146 | sync_lock: Arc::new(TokioMutex::new(())), | |
| 147 | + | email_sync_locks: Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new())), | |
| 148 | + | token_refresh_locks: Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new())), | |
| 144 | 149 | data_dir: app_data_dir, | |
| 145 | 150 | }) | |
| 146 | 151 | } | |
| 152 | + | ||
| 153 | + | /// Gets or creates a per-account token refresh lock. | |
| 154 | + | pub fn token_refresh_lock(&self, account_id: uuid::Uuid) -> Arc<TokioMutex<()>> { | |
| 155 | + | let mut locks = self.token_refresh_locks.lock(); | |
| 156 | + | locks.entry(account_id) | |
| 157 | + | .or_insert_with(|| Arc::new(TokioMutex::new(()))) | |
| 158 | + | .clone() | |
| 159 | + | } | |
| 147 | 160 | } | |
| 148 | 161 | ||
| 149 | 162 | /// Load a SyncKit API key from the keychain, migrating from plaintext file if needed. |
| @@ -73,6 +73,8 @@ | |||
| 73 | 73 | sync_accounts: Arc::new(SqliteSyncAccountRepository::new(pool.clone())), | |
| 74 | 74 | sync_client: parking_lot::RwLock::new(None), | |
| 75 | 75 | sync_lock: Arc::new(tokio::sync::Mutex::new(())), | |
| 76 | + | email_sync_locks: Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new())), | |
| 77 | + | token_refresh_locks: Arc::new(parking_lot::Mutex::new(std::collections::HashMap::new())), | |
| 76 | 78 | data_dir: std::path::PathBuf::from("/tmp/goingson-test"), | |
| 77 | 79 | }; | |
| 78 | 80 |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | 7 | use std::collections::HashMap; | |
| 8 | 8 | ||
| 9 | - | use crate::id_types::{ProjectId, UserId}; | |
| 9 | + | use crate::id_types::{ProjectId, TaskId, UserId}; | |
| 10 | 10 | ||
| 11 | 11 | use crate::error::CoreError; | |
| 12 | 12 | use crate::models::{ | |
| @@ -25,6 +25,10 @@ | |||
| 25 | 25 | pub events_restored: usize, | |
| 26 | 26 | /// Number of emails restored. | |
| 27 | 27 | pub emails_restored: usize, | |
| 28 | + | /// Number of subtasks restored. | |
| 29 | + | pub subtasks_restored: usize, | |
| 30 | + | /// Number of annotations restored. | |
| 31 | + | pub annotations_restored: usize, | |
| 28 | 32 | } | |
| 29 | 33 | ||
| 30 | 34 | /// Pre-parsed backup data for restoration. | |
| @@ -65,7 +69,8 @@ | |||
| 65 | 69 | } | |
| 66 | 70 | } | |
| 67 | 71 | ||
| 68 | - | // Import tasks, remapping project_id references | |
| 72 | + | // Import tasks, remapping project_id references and restoring subtasks/annotations | |
| 73 | + | let mut task_id_map: HashMap<TaskId, TaskId> = HashMap::new(); | |
| 69 | 74 | for task in &input.tasks { | |
| 70 | 75 | if tasks.get_by_id(task.id, user_id).await?.is_none() { | |
| 71 | 76 | let new_task = crate::models::NewTask::builder(&task.description) | |
| @@ -88,8 +93,38 @@ | |||
| 88 | 93 | new_task | |
| 89 | 94 | }; | |
| 90 | 95 | ||
| 91 | - | tasks.create(user_id, new_task.build()).await?; | |
| 96 | + | let created = tasks.create(user_id, new_task.build()).await?; | |
| 97 | + | task_id_map.insert(task.id, created.id); | |
| 92 | 98 | result.tasks_restored += 1; | |
| 99 | + | ||
| 100 | + | // Restore annotations | |
| 101 | + | for annotation in &task.annotations { | |
| 102 | + | if tasks.add_annotation(created.id, user_id, &annotation.note).await?.is_some() { | |
| 103 | + | result.annotations_restored += 1; | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Restore subtasks (text-only; linked subtasks handled in second pass) | |
| 108 | + | for subtask in &task.subtasks { | |
| 109 | + | if subtask.linked_task_id.is_none() { | |
| 110 | + | if tasks.add_subtask(created.id, user_id, &subtask.text).await?.is_some() { | |
| 111 | + | result.subtasks_restored += 1; | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Second pass: restore subtasks with linked_task_id (requires all tasks to exist) | |
| 119 | + | for task in &input.tasks { | |
| 120 | + | let new_parent_id = task_id_map.get(&task.id).copied().unwrap_or(task.id); | |
| 121 | + | for subtask in &task.subtasks { | |
| 122 | + | if let Some(linked_id) = subtask.linked_task_id { | |
| 123 | + | let new_linked_id = task_id_map.get(&linked_id).copied().unwrap_or(linked_id); | |
| 124 | + | if tasks.add_subtask_link(new_parent_id, user_id, new_linked_id).await.ok().flatten().is_some() { | |
| 125 | + | result.subtasks_restored += 1; | |
| 126 | + | } | |
| 127 | + | } | |
| 93 | 128 | } | |
| 94 | 129 | } | |
| 95 | 130 |
| @@ -65,6 +65,9 @@ | |||
| 65 | 65 | /// Maximum scheduled duration in minutes (24 hours). | |
| 66 | 66 | pub const MAX_SCHEDULED_DURATION_MINUTES: i32 = 24 * 60; | |
| 67 | 67 | ||
| 68 | + | /// Maximum relative date offset in days (~10 years). | |
| 69 | + | pub const MAX_RELATIVE_DATE_DAYS: i64 = 3650; | |
| 70 | + | ||
| 68 | 71 | #[cfg(test)] | |
| 69 | 72 | mod tests { | |
| 70 | 73 | use super::*; |
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | pub struct FetchedEmail { | |
| 20 | 20 | pub message_id: Option<String>, | |
| 21 | 21 | pub in_reply_to: Option<String>, | |
| 22 | + | /// First entry from the RFC 2822 References header (the thread root message-ID). | |
| 23 | + | pub references_root: Option<String>, | |
| 22 | 24 | pub from: String, | |
| 23 | 25 | pub to: String, | |
| 24 | 26 | pub subject: String, | |
| @@ -55,6 +57,21 @@ | |||
| 55 | 57 | ) -> Result<SyncProcessResult, CoreError> { | |
| 56 | 58 | let mut result = SyncProcessResult::default(); | |
| 57 | 59 | ||
| 60 | + | // Ensure every email has a message_id for dedup: real if present, else synthetic hash | |
| 61 | + | let mut emails = emails; | |
| 62 | + | for e in &mut emails { | |
| 63 | + | if e.message_id.is_none() { | |
| 64 | + | use std::collections::hash_map::DefaultHasher; | |
| 65 | + | use std::hash::{Hash, Hasher}; | |
| 66 | + | let mut h = DefaultHasher::new(); | |
| 67 | + | e.from.hash(&mut h); | |
| 68 | + | e.to.hash(&mut h); | |
| 69 | + | e.subject.hash(&mut h); | |
| 70 | + | e.date.hash(&mut h); | |
| 71 | + | e.message_id = Some(format!("synth-{:x}", h.finish())); | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 58 | 75 | // Batch check existing message IDs | |
| 59 | 76 | let msg_ids: Vec<&str> = emails.iter() | |
| 60 | 77 | .filter_map(|e| e.message_id.as_deref()) | |
| @@ -78,7 +95,9 @@ | |||
| 78 | 95 | // thread_id groups conversations: use in_reply_to if this is a reply, | |
| 79 | 96 | // otherwise fall back to message_id (starts a new thread). This means | |
| 80 | 97 | // the first email in a thread has thread_id == message_id. | |
| 81 | - | let thread_id = email.in_reply_to.clone().or_else(|| email.message_id.clone()); | |
| 98 | + | let thread_id = email.references_root.clone() | |
| 99 | + | .or_else(|| email.in_reply_to.clone()) | |
| 100 | + | .or_else(|| email.message_id.clone()); | |
| 82 | 101 | ||
| 83 | 102 | if let Some(ref reply_to) = email.in_reply_to { | |
| 84 | 103 | reply_to_ids.push(reply_to.clone()); | |
| @@ -137,6 +156,7 @@ | |||
| 137 | 156 | let email = FetchedEmail { | |
| 138 | 157 | message_id: Some("msg-1@example.com".to_string()), | |
| 139 | 158 | in_reply_to: None, | |
| 159 | + | references_root: None, | |
| 140 | 160 | from: "sender@example.com".to_string(), | |
| 141 | 161 | to: "recipient@example.com".to_string(), | |
| 142 | 162 | subject: "Test".to_string(), |