max / goingson
20 files changed,
+440 insertions,
-234 deletions
| @@ -77,6 +77,18 @@ impl Contact { | |||
| 77 | 77 | ||
| 78 | 78 | // ============ Sub-collection Entities ============ | |
| 79 | 79 | ||
| 80 | + | /// A flattened (name, email) pair for compose autocomplete. | |
| 81 | + | /// | |
| 82 | + | /// Returned by `list_email_directory` — a single JOIN that skips the per-contact | |
| 83 | + | /// sub-collection hydration the compose screen does not need. | |
| 84 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 85 | + | #[serde(rename_all = "camelCase")] | |
| 86 | + | pub struct ContactEmailEntry { | |
| 87 | + | pub name: String, | |
| 88 | + | pub email: String, | |
| 89 | + | pub is_implicit: bool, | |
| 90 | + | } | |
| 91 | + | ||
| 80 | 92 | /// An email address belonging to a contact. | |
| 81 | 93 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 82 | 94 | #[serde(rename_all = "camelCase")] |
| @@ -53,7 +53,7 @@ pub mod validation; | |||
| 53 | 53 | pub mod weekly_review; | |
| 54 | 54 | ||
| 55 | 55 | pub use contact::{ | |
| 56 | - | Contact, ContactCustomField, ContactEmail, ContactPhone, NewContact, NewContactCustomField, | |
| 56 | + | Contact, ContactCustomField, ContactEmail, ContactEmailEntry, ContactPhone, NewContact, NewContactCustomField, | |
| 57 | 57 | NewContactEmail, NewContactPhone, NewSocialHandle, SocialHandle, UpdateContact, | |
| 58 | 58 | }; | |
| 59 | 59 | pub use error::CoreError; |
| @@ -14,7 +14,7 @@ use crate::id_types::{ | |||
| 14 | 14 | use uuid::Uuid; | |
| 15 | 15 | ||
| 16 | 16 | use crate::contact::{ | |
| 17 | - | Contact, ContactCustomField, ContactEmail, ContactPhone, NewContact, NewContactCustomField, | |
| 17 | + | Contact, ContactCustomField, ContactEmail, ContactEmailEntry, ContactPhone, NewContact, NewContactCustomField, | |
| 18 | 18 | NewContactEmail, NewContactPhone, NewSocialHandle, SocialHandle, UpdateContact, | |
| 19 | 19 | }; | |
| 20 | 20 | use crate::error::CoreError; | |
| @@ -937,6 +937,10 @@ pub trait ContactRepository: Send + Sync { | |||
| 937 | 937 | /// Searches across display_name, nickname, company, title, notes, and email addresses. | |
| 938 | 938 | async fn list_filtered(&self, user_id: UserId, search: Option<&str>, tag: Option<&str>, include_implicit: bool) -> Result<Vec<Contact>>; | |
| 939 | 939 | ||
| 940 | + | /// Flat (name, email) directory for compose autocomplete — one JOIN, no | |
| 941 | + | /// per-contact sub-collection hydration. One row per contact email address. | |
| 942 | + | async fn list_email_directory(&self, user_id: UserId, include_implicit: bool) -> Result<Vec<ContactEmailEntry>>; | |
| 943 | + | ||
| 940 | 944 | /// Finds a contact by email address. | |
| 941 | 945 | async fn find_by_email(&self, user_id: UserId, email: &str) -> Result<Option<Contact>>; | |
| 942 | 946 |
| @@ -7,7 +7,7 @@ use async_trait::async_trait; | |||
| 7 | 7 | use sqlx::SqlitePool; | |
| 8 | 8 | use std::collections::{HashMap, HashSet}; | |
| 9 | 9 | use goingson_core::{ | |
| 10 | - | Contact, ContactCustomField, ContactEmail, ContactEmailId, ContactId, ContactPhone, | |
| 10 | + | Contact, ContactCustomField, ContactEmail, ContactEmailEntry, ContactEmailId, ContactId, ContactPhone, | |
| 11 | 11 | ContactPhoneId, ContactRepository, CoreError, CustomFieldId, NewContact, | |
| 12 | 12 | NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle, Result, | |
| 13 | 13 | SocialHandle, SocialHandleId, UpdateContact, UserId, | |
| @@ -584,6 +584,33 @@ impl ContactRepository for SqliteContactRepository { | |||
| 584 | 584 | } | |
| 585 | 585 | ||
| 586 | 586 | #[tracing::instrument(skip_all)] | |
| 587 | + | async fn list_email_directory(&self, user_id: UserId, include_implicit: bool) -> Result<Vec<ContactEmailEntry>> { | |
| 588 | + | // One JOIN, one row per email address — no per-contact sub-collection | |
| 589 | + | // hydration (the compose autocomplete only needs name + address). | |
| 590 | + | let implicit_filter = if include_implicit { "" } else { "AND c.is_implicit = 0" }; | |
| 591 | + | let sql = format!( | |
| 592 | + | "SELECT c.display_name, ce.address, c.is_implicit \ | |
| 593 | + | FROM contacts c JOIN contact_emails ce ON ce.contact_id = c.id \ | |
| 594 | + | WHERE c.user_id = ? {} \ | |
| 595 | + | ORDER BY c.display_name ASC, ce.is_primary DESC", | |
| 596 | + | implicit_filter | |
| 597 | + | ); | |
| 598 | + | let rows: Vec<(String, String, i64)> = sqlx::query_as(&sql) | |
| 599 | + | .bind(user_id.to_string()) | |
| 600 | + | .fetch_all(&self.pool) | |
| 601 | + | .await | |
| 602 | + | .map_err(CoreError::database)?; | |
| 603 | + | Ok(rows | |
| 604 | + | .into_iter() | |
| 605 | + | .map(|(name, email, is_implicit)| ContactEmailEntry { | |
| 606 | + | name, | |
| 607 | + | email, | |
| 608 | + | is_implicit: is_implicit != 0, | |
| 609 | + | }) | |
| 610 | + | .collect()) | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | #[tracing::instrument(skip_all)] | |
| 587 | 614 | async fn find_by_email(&self, user_id: UserId, email: &str) -> Result<Option<Contact>> { | |
| 588 | 615 | let row = sqlx::query_as::<_, ContactRow>( | |
| 589 | 616 | r#" |
| @@ -154,31 +154,43 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 154 | 154 | latest_email_id: String, | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | + | // Rank emails within each thread by recency in a single pass with window | |
| 158 | + | // functions, then keep the latest row per thread. This replaces a | |
| 159 | + | // correlated subquery that rescanned `emails` once per thread group | |
| 160 | + | // (O(threads x emails)); the partition's MAX(received_at) is the rn = 1 | |
| 161 | + | // row, and the thread-wide counts come from window aggregates. | |
| 157 | 162 | let summary_sql = format!( | |
| 158 | - | r#"SELECT | |
| 159 | - | COALESCE(e.thread_id, e.id) AS thread_key, | |
| 160 | - | MAX(e.received_at) AS latest_received_at, | |
| 161 | - | COUNT(*) AS thread_count, | |
| 162 | - | SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) AS unread_count, | |
| 163 | - | (SELECT e2.id FROM emails e2 | |
| 164 | - | WHERE COALESCE(e2.thread_id, e2.id) = COALESCE(e.thread_id, e.id) | |
| 165 | - | AND e2.user_id = ? AND e2.is_draft = 0 {} {} {} | |
| 166 | - | ORDER BY e2.received_at DESC LIMIT 1) AS latest_email_id | |
| 167 | - | FROM emails e | |
| 168 | - | WHERE e.user_id = ? AND e.is_draft = 0 {} {} {} | |
| 169 | - | GROUP BY COALESCE(e.thread_id, e.id) | |
| 163 | + | r#"WITH ranked AS ( | |
| 164 | + | SELECT | |
| 165 | + | e.id AS email_id, | |
| 166 | + | COALESCE(e.thread_id, e.id) AS thread_key, | |
| 167 | + | e.received_at AS received_at, | |
| 168 | + | ROW_NUMBER() OVER ( | |
| 169 | + | PARTITION BY COALESCE(e.thread_id, e.id) | |
| 170 | + | ORDER BY e.received_at DESC, e.id DESC | |
| 171 | + | ) AS rn, | |
| 172 | + | COUNT(*) OVER (PARTITION BY COALESCE(e.thread_id, e.id)) AS thread_count, | |
| 173 | + | SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END) | |
| 174 | + | OVER (PARTITION BY COALESCE(e.thread_id, e.id)) AS unread_count | |
| 175 | + | FROM emails e | |
| 176 | + | WHERE e.user_id = ? AND e.is_draft = 0 {} {} {} | |
| 177 | + | ) | |
| 178 | + | SELECT | |
| 179 | + | thread_key, | |
| 180 | + | received_at AS latest_received_at, | |
| 181 | + | thread_count, | |
| 182 | + | unread_count, | |
| 183 | + | email_id AS latest_email_id | |
| 184 | + | FROM ranked | |
| 185 | + | WHERE rn = 1 | |
| 170 | 186 | ORDER BY latest_received_at DESC | |
| 171 | 187 | LIMIT ? OFFSET ?"#, | |
| 172 | 188 | archived_filter, folder_filter, label_filter, | |
| 173 | - | archived_filter, folder_filter, label_filter, | |
| 174 | 189 | ); | |
| 175 | 190 | ||
| 176 | 191 | let mut summary_q = sqlx::query_as::<_, ThreadSummary>(&summary_sql).bind(&uid); | |
| 177 | 192 | if let Some(f) = folder { summary_q = summary_q.bind(f); } | |
| 178 | 193 | if let Some(l) = label { summary_q = summary_q.bind(l); } | |
| 179 | - | summary_q = summary_q.bind(&uid); | |
| 180 | - | if let Some(f) = folder { summary_q = summary_q.bind(f); } | |
| 181 | - | if let Some(l) = label { summary_q = summary_q.bind(l); } | |
| 182 | 194 | let summaries = summary_q | |
| 183 | 195 | .bind(limit_val) | |
| 184 | 196 | .bind(offset_val) |
| @@ -807,3 +807,45 @@ async fn update_missing_subcollection_returns_none() { | |||
| 807 | 807 | .unwrap(); | |
| 808 | 808 | assert!(result.is_none()); | |
| 809 | 809 | } | |
| 810 | + | ||
| 811 | + | #[tokio::test] | |
| 812 | + | async fn list_email_directory_flattens_addresses_and_honors_implicit() { | |
| 813 | + | let pool = common::setup_test_db().await; | |
| 814 | + | let user_id = common::create_test_user(&pool).await; | |
| 815 | + | let repo = SqliteContactRepository::new(pool); | |
| 816 | + | ||
| 817 | + | let mk = |name: &str, implicit: bool| NewContact { | |
| 818 | + | display_name: name.to_string(), | |
| 819 | + | nickname: None, | |
| 820 | + | company: None, | |
| 821 | + | title: None, | |
| 822 | + | notes: String::new(), | |
| 823 | + | tags: vec![], | |
| 824 | + | birthday: None, | |
| 825 | + | timezone: None, | |
| 826 | + | is_implicit: implicit, | |
| 827 | + | }; | |
| 828 | + | ||
| 829 | + | let alice = repo.create(user_id, mk("Alice", false)).await.unwrap(); | |
| 830 | + | repo.add_email(alice.id, user_id, NewContactEmail { | |
| 831 | + | address: "alice@work.com".to_string(), label: "work".to_string(), is_primary: true, | |
| 832 | + | }).await.unwrap(); | |
| 833 | + | repo.add_email(alice.id, user_id, NewContactEmail { | |
| 834 | + | address: "alice@home.com".to_string(), label: "home".to_string(), is_primary: false, | |
| 835 | + | }).await.unwrap(); | |
| 836 | + | ||
| 837 | + | let ghost = repo.create(user_id, mk("Ghost", true)).await.unwrap(); | |
| 838 | + | repo.add_email(ghost.id, user_id, NewContactEmail { | |
| 839 | + | address: "ghost@example.com".to_string(), label: "other".to_string(), is_primary: true, | |
| 840 | + | }).await.unwrap(); | |
| 841 | + | ||
| 842 | + | // Implicit excluded: only Alice's two addresses. | |
| 843 | + | let explicit = repo.list_email_directory(user_id, false).await.unwrap(); | |
| 844 | + | assert_eq!(explicit.len(), 2, "two addresses for the one explicit contact"); | |
| 845 | + | assert!(explicit.iter().all(|e| e.name == "Alice" && !e.is_implicit)); | |
| 846 | + | ||
| 847 | + | // Implicit included: Alice's two + Ghost's one. | |
| 848 | + | let all = repo.list_email_directory(user_id, true).await.unwrap(); | |
| 849 | + | assert_eq!(all.len(), 3); | |
| 850 | + | assert!(all.iter().any(|e| e.email == "ghost@example.com" && e.is_implicit)); | |
| 851 | + | } |
| @@ -233,6 +233,51 @@ async fn test_email_ordering_by_received_at() { | |||
| 233 | 233 | } | |
| 234 | 234 | ||
| 235 | 235 | #[tokio::test] | |
| 236 | + | async fn test_list_threaded_returns_latest_per_thread() { | |
| 237 | + | let pool = common::setup_test_db().await; | |
| 238 | + | let user_id = common::create_test_user(&pool).await; | |
| 239 | + | let repo = SqliteEmailRepository::new(pool.clone()); | |
| 240 | + | ||
| 241 | + | let mk = |subject: &str, read: bool, hours_ago: i64| NewEmail { | |
| 242 | + | project_id: None, | |
| 243 | + | from_address: "a@example.com".to_string(), | |
| 244 | + | to_address: "me@example.com".to_string(), | |
| 245 | + | subject: subject.to_string(), | |
| 246 | + | body: "body".to_string(), | |
| 247 | + | is_read: read, | |
| 248 | + | received_at: Some(Utc::now() - Duration::hours(hours_ago)), | |
| 249 | + | }; | |
| 250 | + | ||
| 251 | + | // Two emails that will share a thread (older read, newer unread) + a standalone. | |
| 252 | + | let older = repo.create(user_id, mk("Thread root", true, 2)).await.unwrap(); | |
| 253 | + | let newer = repo.create(user_id, mk("Thread reply", false, 1)).await.unwrap(); | |
| 254 | + | let _standalone = repo.create(user_id, mk("Lonely", true, 0)).await.unwrap(); | |
| 255 | + | ||
| 256 | + | // Group the pair into one thread keyed by the root id. | |
| 257 | + | sqlx::query("UPDATE emails SET thread_id = ? WHERE id IN (?, ?)") | |
| 258 | + | .bind(older.id.to_string()) | |
| 259 | + | .bind(older.id.to_string()) | |
| 260 | + | .bind(newer.id.to_string()) | |
| 261 | + | .execute(&pool) | |
| 262 | + | .await | |
| 263 | + | .unwrap(); | |
| 264 | + | ||
| 265 | + | let (threads, total) = repo | |
| 266 | + | .list_threaded(user_id, true, None, None, None, None) | |
| 267 | + | .await | |
| 268 | + | .expect("list_threaded"); | |
| 269 | + | ||
| 270 | + | assert_eq!(total, 2, "the grouped pair counts as one thread, plus the standalone"); | |
| 271 | + | let grouped = threads | |
| 272 | + | .iter() | |
| 273 | + | .find(|t| t.thread_count == 2) | |
| 274 | + | .expect("expected a 2-email thread"); | |
| 275 | + | // The window function must surface the newest message as the thread's face. | |
| 276 | + | assert_eq!(grouped.most_recent_email.id, newer.id); | |
| 277 | + | assert!(grouped.has_unread, "thread contains an unread email"); | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | #[tokio::test] | |
| 236 | 281 | async fn test_count_unread() { | |
| 237 | 282 | let pool = common::setup_test_db().await; | |
| 238 | 283 | let user_id = common::create_test_user(&pool).await; |
| @@ -172,20 +172,14 @@ | |||
| 172 | 172 | ||
| 173 | 173 | async function loadContactEmails() { | |
| 174 | 174 | try { | |
| 175 | - | const contacts = await invoke('list_contacts_filtered', { search: null, tag: null, includeImplicit: true }); | |
| 176 | - | contactEmails = []; | |
| 177 | - | for (const c of contacts) { | |
| 178 | - | const name = c.displayName || c.display_name || ''; | |
| 179 | - | const isImplicit = c.isImplicit || false; | |
| 180 | - | if (c.emails && c.emails.length > 0) { | |
| 181 | - | for (const e of c.emails) { | |
| 182 | - | contactEmails.push({ name, email: e.address, isImplicit }); | |
| 183 | - | } | |
| 184 | - | } | |
| 185 | - | if (c.primaryEmail && !c.emails?.some(e => e.address === c.primaryEmail)) { | |
| 186 | - | contactEmails.push({ name, email: c.primaryEmail, isImplicit }); | |
| 187 | - | } | |
| 188 | - | } | |
| 175 | + | // Lightweight directory (one JOIN, name + address only) instead of | |
| 176 | + | // hydrating every contact's sub-collections just for autocomplete. | |
| 177 | + | const entries = await invoke('list_contact_email_directory', { includeImplicit: true }); | |
| 178 | + | contactEmails = entries.map(e => ({ | |
| 179 | + | name: e.name || '', | |
| 180 | + | email: e.email, | |
| 181 | + | isImplicit: e.isImplicit || false, | |
| 182 | + | })); | |
| 189 | 183 | } catch (_) { /* contacts unavailable */ } | |
| 190 | 184 | } | |
| 191 | 185 |
| @@ -6,6 +6,29 @@ | |||
| 6 | 6 | (function() { | |
| 7 | 7 | 'use strict'; | |
| 8 | 8 | ||
| 9 | + | // Maps a synced DB table (from the `sync:changes-applied` payload) to the view | |
| 10 | + | // cache entities it affects. A table mapping to `[]` has no cached view to bust | |
| 11 | + | // (the re-render alone refetches it). Any table NOT present here triggers a full | |
| 12 | + | // cache bust — keep this in sync with the backend `UPSERT_ORDER` list. | |
| 13 | + | const SYNC_TABLE_TO_ENTITIES = { | |
| 14 | + | projects: ['projects'], | |
| 15 | + | milestones: ['projects'], | |
| 16 | + | tasks: ['tasks'], | |
| 17 | + | subtasks: ['tasks'], | |
| 18 | + | annotations: ['tasks'], | |
| 19 | + | time_sessions: ['tasks'], | |
| 20 | + | attachments: ['tasks', 'projects'], | |
| 21 | + | events: ['events'], | |
| 22 | + | contacts: ['contacts'], | |
| 23 | + | contact_emails: ['contacts'], | |
| 24 | + | contact_phones: ['contacts'], | |
| 25 | + | contact_social_handles: ['contacts'], | |
| 26 | + | contact_custom_fields: ['contacts'], | |
| 27 | + | email_accounts: ['emails'], | |
| 28 | + | sync_accounts: [], | |
| 29 | + | daily_notes: [], | |
| 30 | + | }; | |
| 31 | + | ||
| 9 | 32 | // ============ Application Initialization ============ | |
| 10 | 33 | ||
| 11 | 34 | document.addEventListener('DOMContentLoaded', async () => { | |
| @@ -148,9 +171,30 @@ if (window.__TAURI__) { | |||
| 148 | 171 | refreshCurrentViewData(); | |
| 149 | 172 | }); | |
| 150 | 173 | ||
| 151 | - | // Cloud sync: remote changes applied | |
| 152 | - | listen('sync:changes-applied', () => { | |
| 153 | - | GoingsOn.cache.invalidateAll(); | |
| 174 | + | // Cloud sync: remote changes applied. The payload is the list of DB tables | |
| 175 | + | // the pull touched; invalidate only the affected cache entities so an | |
| 176 | + | // unrelated change (e.g. a task edit) doesn't force the compose screen to | |
| 177 | + | // re-hydrate every contact. Unknown/absent payload falls back to a full bust. | |
| 178 | + | listen('sync:changes-applied', (event) => { | |
| 179 | + | const tables = Array.isArray(event.payload) ? event.payload : null; | |
| 180 | + | if (!tables) { | |
| 181 | + | GoingsOn.cache.invalidateAll(); | |
| 182 | + | } else { | |
| 183 | + | const entities = new Set(); | |
| 184 | + | let unknown = false; | |
| 185 | + | for (const table of tables) { | |
| 186 | + | if (table in SYNC_TABLE_TO_ENTITIES) { | |
| 187 | + | for (const e of SYNC_TABLE_TO_ENTITIES[table]) entities.add(e); | |
| 188 | + | } else { | |
| 189 | + | unknown = true; // be safe about tables we don't have a mapping for | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | if (unknown) { | |
| 193 | + | GoingsOn.cache.invalidateAll(); | |
| 194 | + | } else if (entities.size) { | |
| 195 | + | GoingsOn.cache.invalidate(...entities); | |
| 196 | + | } | |
| 197 | + | } | |
| 154 | 198 | refreshCurrentViewData(); | |
| 155 | 199 | }); | |
| 156 | 200 |
| @@ -152,19 +152,32 @@ async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Resu | |||
| 152 | 152 | .map_err(|e| format!("Failed to get app data dir: {}", e))? | |
| 153 | 153 | .join("backups"); | |
| 154 | 154 | ||
| 155 | - | std::fs::create_dir_all(&backup_dir) | |
| 156 | - | .map_err(|e| format!("Failed to create backup directory: {}", e))?; | |
| 157 | - | ||
| 158 | 155 | let filename = backup_filename(now); | |
| 159 | 156 | let file_path = backup_dir.join(&filename); | |
| 160 | 157 | ||
| 161 | 158 | let export = collect_full_export(state).await.map_err(|e| e.to_string())?; | |
| 162 | - | let size = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?; | |
| 159 | + | let item_count = export.total_count(); | |
| 160 | + | let max_to_keep = settings.max_backups_to_keep as usize; | |
| 161 | + | ||
| 162 | + | // Directory creation, gzip serialization, and pruning are all blocking and | |
| 163 | + | // can take seconds on a large DB — run them on the blocking pool so the | |
| 164 | + | // async reactor isn't stalled (email_sync uses the same pattern). | |
| 165 | + | let log_path = file_path.clone(); | |
| 166 | + | let size = tokio::task::spawn_blocking(move || -> Result<u64, String> { | |
| 167 | + | std::fs::create_dir_all(&backup_dir) | |
| 168 | + | .map_err(|e| format!("Failed to create backup directory: {}", e))?; | |
| 169 | + | let size = write_backup(&export, &file_path) | |
| 170 | + | .map_err(|e| format!("Failed to write backup: {}", e))?; | |
| 171 | + | prune_old_backups(&backup_dir, max_to_keep)?; | |
| 172 | + | Ok(size) | |
| 173 | + | }) | |
| 174 | + | .await | |
| 175 | + | .map_err(|e| format!("Backup task panicked: {}", e))??; | |
| 163 | 176 | ||
| 164 | 177 | info!( | |
| 165 | - | path = %file_path.display(), | |
| 178 | + | path = %log_path.display(), | |
| 166 | 179 | size_bytes = size, | |
| 167 | - | items = export.total_count(), | |
| 180 | + | items = item_count, | |
| 168 | 181 | "Automated backup completed" | |
| 169 | 182 | ); | |
| 170 | 183 | ||
| @@ -175,9 +188,6 @@ async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Resu | |||
| 175 | 188 | .await | |
| 176 | 189 | .map_err(|e| format!("Failed to update last backup time: {}", e))?; | |
| 177 | 190 | ||
| 178 | - | // Prune old backups | |
| 179 | - | prune_old_backups(&backup_dir, settings.max_backups_to_keep as usize)?; | |
| 180 | - | ||
| 181 | 191 | Ok(()) | |
| 182 | 192 | } | |
| 183 | 193 |
| @@ -402,6 +402,21 @@ pub async fn list_contacts_filtered( | |||
| 402 | 402 | Ok(contacts.into_iter().map(ContactResponse::from).collect()) | |
| 403 | 403 | } | |
| 404 | 404 | ||
| 405 | + | /// Lightweight name+email directory for compose autocomplete. | |
| 406 | + | /// | |
| 407 | + | /// Avoids the full contact hydration (4 sub-queries per contact) that | |
| 408 | + | /// `list_contacts_filtered` performs; returns one entry per email address. | |
| 409 | + | #[tauri::command] | |
| 410 | + | #[instrument(skip_all)] | |
| 411 | + | pub async fn list_contact_email_directory( | |
| 412 | + | state: State<'_, Arc<AppState>>, | |
| 413 | + | include_implicit: Option<bool>, | |
| 414 | + | ) -> Result<Vec<goingson_core::ContactEmailEntry>, ApiError> { | |
| 415 | + | Ok(state.contacts | |
| 416 | + | .list_email_directory(DESKTOP_USER_ID, include_implicit.unwrap_or(false)) | |
| 417 | + | .await?) | |
| 418 | + | } | |
| 419 | + | ||
| 405 | 420 | /// Finds a contact by email address (case-insensitive). | |
| 406 | 421 | #[tauri::command] | |
| 407 | 422 | #[instrument(skip_all)] |
| @@ -72,7 +72,7 @@ pub struct SyncSettingsInput { | |||
| 72 | 72 | ||
| 73 | 73 | /// Extract the sync client from state. Clones the Arc for use across await points. | |
| 74 | 74 | fn get_sync_client(state: &AppState) -> Option<std::sync::Arc<synckit_client::SyncKitClient>> { | |
| 75 | - | state.sync_client.read().unwrap_or_else(|e| e.into_inner()).clone() | |
| 75 | + | state.read_recovering() | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | 78 | fn require_sync_client(state: &AppState) -> Result<std::sync::Arc<synckit_client::SyncKitClient>, ApiError> { | |
| @@ -290,7 +290,8 @@ pub async fn sync_now( | |||
| 290 | 290 | }; | |
| 291 | 291 | ||
| 292 | 292 | if result.pulled > 0 { | |
| 293 | - | let _ = app.emit("sync:changes-applied", ()); | |
| 293 | + | // Carry the changed tables so the UI invalidates selectively. | |
| 294 | + | let _ = app.emit("sync:changes-applied", &result.pulled_tables); | |
| 294 | 295 | } | |
| 295 | 296 | ||
| 296 | 297 | // Cleanup after manual sync too |
| @@ -14,6 +14,11 @@ type ImapSession = async_imap::Session<tokio_native_tls::TlsStream<TcpStream>>; | |||
| 14 | 14 | /// Maximum email size to download (25 MB). Emails exceeding this are skipped during sync. | |
| 15 | 15 | const MAX_EMAIL_SIZE: u32 = 25 * 1024 * 1024; | |
| 16 | 16 | ||
| 17 | + | /// How many UIDs to body-fetch per IMAP command. Bounds the command length and | |
| 18 | + | /// the in-flight fetch pipeline so a large mailbox doesn't issue one enormous | |
| 19 | + | /// fetch. | |
| 20 | + | const IMAP_FETCH_BATCH: usize = 50; | |
| 21 | + | ||
| 17 | 22 | /// Raw attachment data extracted during IMAP RFC822 parse. | |
| 18 | 23 | #[derive(Debug, Clone)] | |
| 19 | 24 | pub struct AttachmentPart { | |
| @@ -227,121 +232,55 @@ impl ImapClient { | |||
| 227 | 232 | return Ok((Vec::new(), debug.join(", "))); | |
| 228 | 233 | } | |
| 229 | 234 | ||
| 230 | - | let safe_uid_set = safe_seqs.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(","); | |
| 231 | - | ||
| 232 | - | // Fetch full bodies only for safe-sized emails | |
| 233 | - | let mut messages = session | |
| 234 | - | .uid_fetch(&safe_uid_set, "(UID FLAGS RFC822)") | |
| 235 | - | .await | |
| 236 | - | .map_err(|e| format!("Fetch error: {}", e))?; | |
| 237 | - | ||
| 238 | 235 | let mut emails = Vec::new(); | |
| 239 | 236 | let folder_name = folder.to_string(); | |
| 240 | 237 | let mut msg_count = 0; | |
| 241 | 238 | let mut body_count = 0; | |
| 242 | 239 | let mut parse_errors = 0; | |
| 243 | 240 | ||
| 244 | - | while let Some(result) = messages.next().await { | |
| 245 | - | msg_count += 1; | |
| 246 | - | let message = match result { | |
| 247 | - | Ok(m) => m, | |
| 248 | - | Err(e) => { | |
| 249 | - | debug.push(format!("msg_err: {}", e)); | |
| 250 | - | continue; | |
| 251 | - | } | |
| 252 | - | }; | |
| 253 | - | let uid = match message.uid { | |
| 254 | - | Some(u) => u, | |
| 255 | - | None => { | |
| 256 | - | tracing::warn!(folder = %folder_name, "IMAP message missing UID, skipping"); | |
| 257 | - | continue; | |
| 258 | - | } | |
| 259 | - | }; | |
| 260 | - | ||
| 261 | - | // Check if \Seen flag is present (email has been read) | |
| 262 | - | let is_read = message.flags().any(|f| { | |
| 263 | - | matches!(f, async_imap::types::Flag::Seen) | |
| 264 | - | }); | |
| 241 | + | // Fetch full bodies in bounded UID batches so neither the IMAP command | |
| 242 | + | // nor the in-flight fetch pipeline grows with the size of the mailbox. | |
| 243 | + | for chunk in safe_seqs.chunks(IMAP_FETCH_BATCH) { | |
| 244 | + | let safe_uid_set = chunk.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(","); | |
| 245 | + | let mut messages = session | |
| 246 | + | .uid_fetch(&safe_uid_set, "(UID FLAGS RFC822)") | |
| 247 | + | .await | |
| 248 | + | .map_err(|e| format!("Fetch error: {}", e))?; | |
| 265 | 249 | ||
| 266 | - | if let Some(body) = message.body() { | |
| 267 | - | body_count += 1; | |
| 268 | - | match mailparse::parse_mail(body) { | |
| 269 | - | Ok(parsed) => { | |
| 270 | - | let message_id = parsed | |
| 271 | - | .headers | |
| 272 | - | .iter() | |
| 273 | - | .find(|h| h.get_key().to_lowercase() == "message-id") | |
| 274 | - | .map(|h| h.get_value()); | |
| 275 | - | ||
| 276 | - | let in_reply_to = parsed | |
| 277 | - | .headers | |
| 278 | - | .iter() | |
| 279 | - | .find(|h| h.get_key().to_lowercase() == "in-reply-to") | |
| 280 | - | .map(|h| h.get_value()); | |
| 281 | - | ||
| 282 | - | let references_root = extract_references_root(&parsed.headers); | |
| 283 | - | ||
| 284 | - | let from = parsed | |
| 285 | - | .headers | |
| 286 | - | .iter() | |
| 287 | - | .find(|h| h.get_key().to_lowercase() == "from") | |
| 288 | - | .map(|h| h.get_value()) | |
| 289 | - | .unwrap_or_default(); | |
| 290 | - | ||
| 291 | - | let to = parsed | |
| 292 | - | .headers | |
| 293 | - | .iter() | |
| 294 | - | .find(|h| h.get_key().to_lowercase() == "to") | |
| 295 | - | .map(|h| h.get_value()) | |
| 296 | - | .unwrap_or_default(); | |
| 297 | - | ||
| 298 | - | let subject = parsed | |
| 299 | - | .headers | |
| 300 | - | .iter() | |
| 301 | - | .find(|h| h.get_key().to_lowercase() == "subject") | |
| 302 | - | .map(|h| h.get_value()) | |
| 303 | - | .unwrap_or_default(); | |
| 304 | - | ||
| 305 | - | let date_str = parsed | |
| 306 | - | .headers | |
| 307 | - | .iter() | |
| 308 | - | .find(|h| h.get_key().to_lowercase() == "date") | |
| 309 | - | .map(|h| h.get_value()); | |
| 310 | - | ||
| 311 | - | let date = date_str | |
| 312 | - | .and_then(|s| mailparse::dateparse(&s).ok()) | |
| 313 | - | .and_then(|ts| Utc.timestamp_opt(ts, 0).single()) | |
| 314 | - | .unwrap_or(DateTime::UNIX_EPOCH); | |
| 315 | - | ||
| 316 | - | let (body_text, html_body) = Self::extract_body_with_html(&parsed); | |
| 317 | - | let attachments = extract_attachment_parts(&parsed); | |
| 318 | - | ||
| 319 | - | emails.push(ParsedEmail { | |
| 320 | - | message_id, | |
| 321 | - | in_reply_to, | |
| 322 | - | references_root, | |
| 323 | - | imap_uid: uid, | |
| 324 | - | source_folder: folder_name.clone(), | |
| 325 | - | from, | |
| 326 | - | to, | |
| 327 | - | subject, | |
| 328 | - | body: body_text, | |
| 329 | - | html_body, | |
| 330 | - | date, | |
| 331 | - | is_read, | |
| 332 | - | attachments, | |
| 333 | - | }); | |
| 334 | - | } | |
| 250 | + | while let Some(result) = messages.next().await { | |
| 251 | + | msg_count += 1; | |
| 252 | + | let message = match result { | |
| 253 | + | Ok(m) => m, | |
| 335 | 254 | Err(e) => { | |
| 336 | - | tracing::debug!(uid, folder = %folder_name, error = %e, "Failed to parse email"); | |
| 337 | - | parse_errors += 1; | |
| 255 | + | debug.push(format!("msg_err: {}", e)); | |
| 256 | + | continue; | |
| 257 | + | } | |
| 258 | + | }; | |
| 259 | + | let uid = match message.uid { | |
| 260 | + | Some(u) => u, | |
| 261 | + | None => { | |
| 262 | + | tracing::warn!(folder = %folder_name, "IMAP message missing UID, skipping"); | |
| 263 | + | continue; | |
| 264 | + | } | |
| 265 | + | }; | |
| 266 | + | ||
| 267 | + | // Check if \Seen flag is present (email has been read) | |
| 268 | + | let is_read = message.flags().any(|f| matches!(f, async_imap::types::Flag::Seen)); | |
| 269 | + | ||
| 270 | + | if let Some(body) = message.body() { | |
| 271 | + | body_count += 1; | |
| 272 | + | match mailparse::parse_mail(body) { | |
| 273 | + | Ok(parsed) => emails.push(Self::build_parsed_email(&parsed, uid, &folder_name, is_read)), | |
| 274 | + | Err(e) => { | |
| 275 | + | tracing::debug!(uid, folder = %folder_name, error = %e, "Failed to parse email"); | |
| 276 | + | parse_errors += 1; | |
| 277 | + | } | |
| 338 | 278 | } | |
| 339 | 279 | } | |
| 340 | 280 | } | |
| 341 | 281 | } | |
| 342 | 282 | ||
| 343 | 283 | debug.push(format!("msgs: {}, bodies: {}, parsed: {}, errs: {}", msg_count, body_count, emails.len(), parse_errors)); | |
| 344 | - | drop(messages); | |
| 345 | 284 | session.logout().await.ok(); | |
| 346 | 285 | Ok((emails, debug.join(", "))) | |
| 347 | 286 | } | |
| @@ -473,13 +412,6 @@ impl ImapClient { | |||
| 473 | 412 | }); | |
| 474 | 413 | } | |
| 475 | 414 | ||
| 476 | - | let safe_uid_set = safe_uids.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(","); | |
| 477 | - | ||
| 478 | - | let mut messages = session | |
| 479 | - | .uid_fetch(&safe_uid_set, "(UID FLAGS RFC822)") | |
| 480 | - | .await | |
| 481 | - | .map_err(|e| format!("UID fetch error: {}", e))?; | |
| 482 | - | ||
| 483 | 415 | let mut emails = Vec::new(); | |
| 484 | 416 | let folder_name = folder.to_string(); | |
| 485 | 417 | let mut msg_count = 0; | |
| @@ -488,84 +420,50 @@ impl ImapClient { | |||
| 488 | 420 | // Seed max_uid from all UIDs (including skipped large ones) so they aren't re-fetched | |
| 489 | 421 | let mut max_uid: Option<u32> = uids.iter().copied().max(); | |
| 490 | 422 | ||
| 491 | - | while let Some(result) = messages.next().await { | |
| 492 | - | msg_count += 1; | |
| 493 | - | let message = match result { | |
| 494 | - | Ok(m) => m, | |
| 495 | - | Err(e) => { | |
| 496 | - | debug.push(format!("msg_err: {}", e)); | |
| 497 | - | continue; | |
| 498 | - | } | |
| 499 | - | }; | |
| 500 | - | let uid = match message.uid { | |
| 501 | - | Some(u) => u, | |
| 502 | - | None => { | |
| 503 | - | tracing::warn!(folder = %folder_name, "IMAP message missing UID, skipping"); | |
| 504 | - | continue; | |
| 505 | - | } | |
| 506 | - | }; | |
| 423 | + | // Fetch full bodies in bounded UID batches so neither the IMAP command | |
| 424 | + | // nor the in-flight fetch pipeline grows with the size of the mailbox. | |
| 425 | + | for chunk in safe_uids.chunks(IMAP_FETCH_BATCH) { | |
| 426 | + | let safe_uid_set = chunk.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(","); | |
| 427 | + | let mut messages = session | |
| 428 | + | .uid_fetch(&safe_uid_set, "(UID FLAGS RFC822)") | |
| 429 | + | .await | |
| 430 | + | .map_err(|e| format!("UID fetch error: {}", e))?; | |
| 507 | 431 | ||
| 508 | - | max_uid = Some(max_uid.map_or(uid, |m: u32| m.max(uid))); | |
| 509 | - | ||
| 510 | - | let is_read = message.flags().any(|f| matches!(f, async_imap::types::Flag::Seen)); | |
| 511 | - | ||
| 512 | - | if let Some(body) = message.body() { | |
| 513 | - | body_count += 1; | |
| 514 | - | match mailparse::parse_mail(body) { | |
| 515 | - | Ok(parsed) => { | |
| 516 | - | let message_id = parsed.headers.iter() | |
| 517 | - | .find(|h| h.get_key().to_lowercase() == "message-id") | |
| 518 | - | .map(|h| h.get_value()); | |
| 519 | - | let in_reply_to = parsed.headers.iter() | |
| 520 | - | .find(|h| h.get_key().to_lowercase() == "in-reply-to") | |
| 521 | - | .map(|h| h.get_value()); | |
| 522 | - | let references_root = extract_references_root(&parsed.headers); | |
| 523 | - | let from = parsed.headers.iter() | |
| 524 | - | .find(|h| h.get_key().to_lowercase() == "from") | |
| 525 | - | .map(|h| h.get_value()).unwrap_or_default(); | |
| 526 | - | let to = parsed.headers.iter() | |
| 527 | - | .find(|h| h.get_key().to_lowercase() == "to") | |
| 528 | - | .map(|h| h.get_value()).unwrap_or_default(); | |
| 529 | - | let subject = parsed.headers.iter() | |
| 530 | - | .find(|h| h.get_key().to_lowercase() == "subject") | |
| 531 | - | .map(|h| h.get_value()).unwrap_or_default(); | |
| 532 | - | let date_str = parsed.headers.iter() | |
| 533 | - | .find(|h| h.get_key().to_lowercase() == "date") | |
| 534 | - | .map(|h| h.get_value()); | |
| 535 | - | let date = date_str | |
| 536 | - | .and_then(|s| mailparse::dateparse(&s).ok()) | |
| 537 | - | .and_then(|ts| Utc.timestamp_opt(ts, 0).single()) | |
| 538 | - | .unwrap_or(DateTime::UNIX_EPOCH); | |
| 539 | - | ||
| 540 | - | let (body_text, html_body) = Self::extract_body_with_html(&parsed); | |
| 541 | - | let attachments = extract_attachment_parts(&parsed); | |
| 542 | - | ||
| 543 | - | emails.push(ParsedEmail { | |
| 544 | - | message_id, | |
| 545 | - | in_reply_to, | |
| 546 | - | references_root, | |
| 547 | - | imap_uid: uid, | |
| 548 | - | source_folder: folder_name.clone(), | |
| 549 | - | from, | |
| 550 | - | to, | |
| 551 | - | subject, | |
| 552 | - | body: body_text, | |
| 553 | - | html_body, | |
| 554 | - | date, | |
| 555 | - | is_read, | |
| 556 | - | attachments, | |
| 557 | - | }); | |
| 558 | - | } | |
| 432 | + | while let Some(result) = messages.next().await { | |
| 433 | + | msg_count += 1; | |
| 434 | + | let message = match result { | |
| 435 | + | Ok(m) => m, | |
| 559 | 436 | Err(e) => { | |
| 560 | - | tracing::debug!(uid, folder = %folder_name, error = %e, "Failed to parse email"); | |
| 561 | - | parse_errors += 1; | |
| 437 | + | debug.push(format!("msg_err: {}", e)); | |
| 438 | + | continue; | |
| 439 | + | } | |
| 440 | + | }; | |
| 441 | + | let uid = match message.uid { | |
| 442 | + | Some(u) => u, | |
| 443 | + | None => { | |
| 444 | + | tracing::warn!(folder = %folder_name, "IMAP message missing UID, skipping"); | |
| 445 | + | continue; | |
| 446 | + | } | |
| 447 | + | }; | |
| 448 | + | ||
| 449 | + | max_uid = Some(max_uid.map_or(uid, |m: u32| m.max(uid))); | |
| 450 | + | ||
| 451 | + | let is_read = message.flags().any(|f| matches!(f, async_imap::types::Flag::Seen)); | |
| 452 | + | ||
| 453 | + | if let Some(body) = message.body() { | |
| 454 | + | body_count += 1; | |
| 455 | + | match mailparse::parse_mail(body) { | |
| 456 | + | Ok(parsed) => emails.push(Self::build_parsed_email(&parsed, uid, &folder_name, is_read)), | |
| 457 | + | Err(e) => { | |
| 458 | + | tracing::debug!(uid, folder = %folder_name, error = %e, "Failed to parse email"); | |
| 459 | + | parse_errors += 1; | |
| 460 | + | } | |
| 562 | 461 | } | |
| 563 | 462 | } | |
| 564 | 463 | } | |
| 565 | 464 | } | |
| 566 | 465 | ||
| 567 | 466 | debug.push(format!("msgs: {}, bodies: {}, parsed: {}, errs: {}", msg_count, body_count, emails.len(), parse_errors)); | |
| 568 | - | drop(messages); | |
| 569 | 467 | session.logout().await.ok(); | |
| 570 | 468 | ||
| 571 | 469 | Ok(FolderFetchResult { | |
| @@ -688,6 +586,49 @@ impl ImapClient { | |||
| 688 | 586 | Ok(format!("Folder '{}': exists={}, recent={}, search_all={}", folder, exists, recent, count)) | |
| 689 | 587 | } | |
| 690 | 588 | ||
| 589 | + | /// Build a `ParsedEmail` from a parsed message and its IMAP metadata. | |
| 590 | + | /// | |
| 591 | + | /// Shared by both fetch loops (full-folder and incremental) so the header | |
| 592 | + | /// extraction lives in one place. | |
| 593 | + | fn build_parsed_email( | |
| 594 | + | parsed: &mailparse::ParsedMail, | |
| 595 | + | uid: u32, | |
| 596 | + | folder_name: &str, | |
| 597 | + | is_read: bool, | |
| 598 | + | ) -> ParsedEmail { | |
| 599 | + | let header = |key: &str| { | |
| 600 | + | parsed | |
| 601 | + | .headers | |
| 602 | + | .iter() | |
| 603 | + | .find(|h| h.get_key().to_lowercase() == key) | |
| 604 | + | .map(|h| h.get_value()) | |
| 605 | + | }; | |
| 606 | + | ||
| 607 | + | let date = header("date") | |
| 608 | + | .and_then(|s| mailparse::dateparse(&s).ok()) | |
| 609 | + | .and_then(|ts| Utc.timestamp_opt(ts, 0).single()) | |
| 610 | + | .unwrap_or(DateTime::UNIX_EPOCH); | |
| 611 | + | ||
| 612 | + | let (body_text, html_body) = Self::extract_body_with_html(parsed); | |
| 613 | + | let attachments = extract_attachment_parts(parsed); | |
| 614 | + | ||
| 615 | + | ParsedEmail { | |
| 616 | + | message_id: header("message-id"), | |
| 617 | + | in_reply_to: header("in-reply-to"), | |
| 618 | + | references_root: extract_references_root(&parsed.headers), | |
| 619 | + | imap_uid: uid, | |
| 620 | + | source_folder: folder_name.to_string(), | |
| 621 | + | from: header("from").unwrap_or_default(), | |
| 622 | + | to: header("to").unwrap_or_default(), | |
| 623 | + | subject: header("subject").unwrap_or_default(), | |
| 624 | + | body: body_text, | |
| 625 | + | html_body, | |
| 626 | + | date, | |
| 627 | + | is_read, | |
| 628 | + | attachments, | |
| 629 | + | } | |
| 630 | + | } | |
| 631 | + | ||
| 691 | 632 | /// Extracts body text and optionally HTML from a parsed email. | |
| 692 | 633 | /// Returns (plain_text_body, optional_html_body). | |
| 693 | 634 | fn extract_body_with_html(mail: &mailparse::ParsedMail) -> (String, Option<String>) { |
| @@ -90,6 +90,13 @@ async fn check_and_sync_accounts( | |||
| 90 | 90 | ||
| 91 | 91 | debug!("Email sync scheduler: {} account(s) need sync", accounts.len()); | |
| 92 | 92 | ||
| 93 | + | // A failing account never updates last_sync_at, so it stays "due" and remains | |
| 94 | + | // in this list every tick; any failure_counts key absent here belongs to an | |
| 95 | + | // account that was deleted or had sync disabled. Prune them after the pass so | |
| 96 | + | // the map can't grow without bound over a long-running session. | |
| 97 | + | let due_account_ids: std::collections::HashSet<String> = | |
| 98 | + | accounts.iter().map(|a| a.id.to_string()).collect(); | |
| 99 | + | ||
| 93 | 100 | // Sync each account independently — a failure in one account (e.g. expired | |
| 94 | 101 | // token, network issue) must not prevent other accounts from syncing. | |
| 95 | 102 | for account in accounts { | |
| @@ -151,6 +158,8 @@ async fn check_and_sync_accounts( | |||
| 151 | 158 | } | |
| 152 | 159 | } | |
| 153 | 160 | ||
| 161 | + | failure_counts.retain(|account_id, _| due_account_ids.contains(account_id)); | |
| 162 | + | ||
| 154 | 163 | Ok(()) | |
| 155 | 164 | } | |
| 156 | 165 |
| @@ -159,6 +159,7 @@ macro_rules! all_commands { | |||
| 159 | 159 | $crate::commands::list_events_for_contact, | |
| 160 | 160 | $crate::commands::list_emails_for_contact, | |
| 161 | 161 | $crate::commands::list_contacts_filtered, | |
| 162 | + | $crate::commands::list_contact_email_directory, | |
| 162 | 163 | $crate::commands::bulk_delete_contacts, | |
| 163 | 164 | $crate::commands::bulk_tag_contacts, | |
| 164 | 165 | // Snooze Options |
| @@ -30,6 +30,28 @@ pub const SYNC_SERVER_URL: &str = "https://makenot.work"; | |||
| 30 | 30 | /// The API key is a public client identifier, not a secret. | |
| 31 | 31 | const SYNCKIT_TOML: &str = include_str!("../../synckit.toml"); | |
| 32 | 32 | ||
| 33 | + | /// Holds the optional sync client behind a poison-recovering lock. | |
| 34 | + | /// | |
| 35 | + | /// The inner `RwLock` is private to this newtype, so no caller can write | |
| 36 | + | /// `.read().expect("poisoned")` and crash a background loop on a poisoned lock. | |
| 37 | + | /// The only access is [`SyncClientCell::get`], which recovers instead of | |
| 38 | + | /// panicking — the constructive fix for the recurring sync-scheduler poison nit. | |
| 39 | + | #[derive(Default)] | |
| 40 | + | pub struct SyncClientCell(RwLock<Option<Arc<SyncKitClient>>>); | |
| 41 | + | ||
| 42 | + | impl SyncClientCell { | |
| 43 | + | /// Wrap an initial (possibly absent) client. | |
| 44 | + | pub(crate) fn new(client: Option<Arc<SyncKitClient>>) -> Self { | |
| 45 | + | Self(RwLock::new(client)) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// Clone out the current client, recovering from a poisoned lock rather | |
| 49 | + | /// than panicking. | |
| 50 | + | pub(crate) fn get(&self) -> Option<Arc<SyncKitClient>> { | |
| 51 | + | self.0.read().unwrap_or_else(|e| e.into_inner()).clone() | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 33 | 55 | /// Application state holding database connections and repositories | |
| 34 | 56 | pub struct AppState { | |
| 35 | 57 | pub pool: SqlitePool, | |
| @@ -49,7 +71,7 @@ pub struct AppState { | |||
| 49 | 71 | pub monthly_reviews: Arc<dyn MonthlyReviewRepository>, | |
| 50 | 72 | pub backup_settings: Arc<dyn BackupSettingsRepository>, | |
| 51 | 73 | pub sync_accounts: Arc<dyn SyncAccountRepository>, | |
| 52 | - | pub sync_client: RwLock<Option<Arc<SyncKitClient>>>, | |
| 74 | + | pub sync_client: SyncClientCell, | |
| 53 | 75 | pub sync_lock: Arc<TokioMutex<()>>, | |
| 54 | 76 | /// Per-account email sync locks to prevent concurrent syncs on the same account. | |
| 55 | 77 | pub email_sync_locks: Arc<Mutex<std::collections::HashSet<goingson_core::EmailAccountId>>>, | |
| @@ -163,7 +185,7 @@ impl AppState { | |||
| 163 | 185 | monthly_reviews, | |
| 164 | 186 | backup_settings, | |
| 165 | 187 | sync_accounts, | |
| 166 | - | sync_client: RwLock::new(sync_client.map(Arc::new)), | |
| 188 | + | sync_client: SyncClientCell::new(sync_client.map(Arc::new)), | |
| 167 | 189 | sync_lock: Arc::new(TokioMutex::new(())), | |
| 168 | 190 | email_sync_locks: Arc::new(Mutex::new(std::collections::HashSet::new())), | |
| 169 | 191 | token_refresh_locks: Arc::new(Mutex::new(std::collections::HashMap::new())), | |
| @@ -173,6 +195,13 @@ impl AppState { | |||
| 173 | 195 | }) | |
| 174 | 196 | } | |
| 175 | 197 | ||
| 198 | + | /// Clone out the current sync client, recovering from a poisoned lock | |
| 199 | + | /// instead of panicking. Background loops must use this (never a raw | |
| 200 | + | /// `.read().expect(...)`), so a writer panic can't kill sync for the session. | |
| 201 | + | pub(crate) fn read_recovering(&self) -> Option<Arc<SyncKitClient>> { | |
| 202 | + | self.sync_client.get() | |
| 203 | + | } | |
| 204 | + | ||
| 176 | 205 | /// Gets or creates a per-account token refresh lock. | |
| 177 | 206 | pub fn token_refresh_lock(&self, account_id: uuid::Uuid) -> Arc<TokioMutex<()>> { | |
| 178 | 207 | let mut locks = self.token_refresh_locks.lock().unwrap_or_else(|e| e.into_inner()); |
| @@ -72,7 +72,7 @@ pub async fn start_sync_scheduler(app: tauri::AppHandle, cancel: CancellationTok | |||
| 72 | 72 | } | |
| 73 | 73 | }; | |
| 74 | 74 | ||
| 75 | - | let client: Arc<synckit_client::SyncKitClient> = match state.sync_client.read().expect("sync_client poisoned").clone() { | |
| 75 | + | let client: Arc<synckit_client::SyncKitClient> = match state.read_recovering() { | |
| 76 | 76 | Some(c) => c, | |
| 77 | 77 | None => continue, | |
| 78 | 78 | }; | |
| @@ -189,7 +189,8 @@ pub async fn start_sync_scheduler(app: tauri::AppHandle, cancel: CancellationTok | |||
| 189 | 189 | info!("Auto-sync: pushed {}, pulled {}", result.pushed, result.pulled); | |
| 190 | 190 | } | |
| 191 | 191 | if result.pulled > 0 { | |
| 192 | - | let _ = app.emit("sync:changes-applied", ()); | |
| 192 | + | // Carry the changed tables so the UI invalidates selectively. | |
| 193 | + | let _ = app.emit("sync:changes-applied", &result.pulled_tables); | |
| 193 | 194 | } | |
| 194 | 195 | } | |
| 195 | 196 | Err(e) => { |
| @@ -101,6 +101,9 @@ pub(crate) const DELETE_ORDER: &[&str] = &[ | |||
| 101 | 101 | pub struct SyncResult { | |
| 102 | 102 | pub pushed: i64, | |
| 103 | 103 | pub pulled: i64, | |
| 104 | + | /// Distinct DB tables touched by the pull, for selective UI cache invalidation. | |
| 105 | + | #[serde(skip)] | |
| 106 | + | pub pulled_tables: Vec<String>, | |
| 104 | 107 | } | |
| 105 | 108 | ||
| 106 | 109 | // -- High-level sync -- | |
| @@ -122,7 +125,10 @@ pub async fn perform_sync_with_blobs( | |||
| 122 | 125 | ||
| 123 | 126 | // Push first, then pull | |
| 124 | 127 | let pushed = push_changes(pool, client, device_id).await?; | |
| 125 | - | let pulled = pull_changes(pool, client, device_id).await?; | |
| 128 | + | let pull_outcome = pull_changes(pool, client, device_id).await?; | |
| 129 | + | let pulled = pull_outcome.applied; | |
| 130 | + | let mut pulled_tables: Vec<String> = pull_outcome.changed_tables.into_iter().collect(); | |
| 131 | + | pulled_tables.sort(); | |
| 126 | 132 | ||
| 127 | 133 | // Sync blobs after metadata (upload local, download missing) | |
| 128 | 134 | if let Some(dir) = data_dir { | |
| @@ -138,7 +144,7 @@ pub async fn perform_sync_with_blobs( | |||
| 138 | 144 | let now = Utc::now().to_rfc3339(); | |
| 139 | 145 | set_sync_state(pool, "last_sync_at", &now).await?; | |
| 140 | 146 | ||
| 141 | - | Ok(SyncResult { pushed, pulled }) | |
| 147 | + | Ok(SyncResult { pushed, pulled, pulled_tables }) | |
| 142 | 148 | } | |
| 143 | 149 | ||
| 144 | 150 | // -- Initial snapshot -- |
| @@ -18,10 +18,11 @@ pub async fn pull_changes( | |||
| 18 | 18 | pool: &SqlitePool, | |
| 19 | 19 | client: &SyncKitClient, | |
| 20 | 20 | device_id: Uuid, | |
| 21 | - | ) -> Result<i64, CoreError> { | |
| 21 | + | ) -> Result<PullOutcome, CoreError> { | |
| 22 | 22 | let cursor_str = get_sync_state(pool, "pull_cursor").await?; | |
| 23 | 23 | let mut cursor: i64 = cursor_str.parse().unwrap_or(0); | |
| 24 | 24 | let mut total_applied: i64 = 0; | |
| 25 | + | let mut changed_tables: std::collections::HashSet<String> = std::collections::HashSet::new(); | |
| 25 | 26 | ||
| 26 | 27 | // Read local pending changes once (unpushed changelog entries) | |
| 27 | 28 | let local_pending = read_local_pending(pool).await?; | |
| @@ -45,6 +46,7 @@ pub async fn pull_changes( | |||
| 45 | 46 | if !clean.is_empty() { | |
| 46 | 47 | let clean_entries: Vec<ChangeEntry> = | |
| 47 | 48 | clean.into_iter().map(|p| p.entry).collect(); | |
| 49 | + | changed_tables.extend(clean_entries.iter().map(|e| e.table.clone())); | |
| 48 | 50 | apply_remote_changes(pool, clean_entries).await?; | |
| 49 | 51 | } | |
| 50 | 52 | ||
| @@ -76,6 +78,7 @@ pub async fn pull_changes( | |||
| 76 | 78 | ||
| 77 | 79 | resolved_count = resolved_entries.len() as i64; | |
| 78 | 80 | if !resolved_entries.is_empty() { | |
| 81 | + | changed_tables.extend(resolved_entries.iter().map(|e| e.table.clone())); | |
| 79 | 82 | apply_remote_changes(pool, resolved_entries).await?; | |
| 80 | 83 | } | |
| 81 | 84 | ||
| @@ -102,7 +105,17 @@ pub async fn pull_changes( | |||
| 102 | 105 | if total_applied > 0 { | |
| 103 | 106 | debug!("Pulled and applied {} remote changes", total_applied); | |
| 104 | 107 | } | |
| 105 | - | Ok(total_applied) | |
| 108 | + | Ok(PullOutcome { applied: total_applied, changed_tables }) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Result of a pull: how many changes landed and which tables they touched. | |
| 112 | + | /// | |
| 113 | + | /// The table set drives selective cache invalidation in the UI so a pull that | |
| 114 | + | /// only touched, say, `tasks` doesn't force the compose screen to re-hydrate | |
| 115 | + | /// every contact. | |
| 116 | + | pub struct PullOutcome { | |
| 117 | + | pub applied: i64, | |
| 118 | + | pub changed_tables: std::collections::HashSet<String>, | |
| 106 | 119 | } | |
| 107 | 120 | ||
| 108 | 121 | /// Read unpushed changelog entries as ChangeEntry values for conflict detection. |
| @@ -72,7 +72,7 @@ pub async fn setup_test_state() -> (Arc<AppState>, UserId) { | |||
| 72 | 72 | monthly_reviews: Arc::new(SqliteMonthlyReviewRepository::new(pool.clone())), | |
| 73 | 73 | backup_settings: Arc::new(SqliteBackupSettingsRepository::new(pool.clone())), | |
| 74 | 74 | sync_accounts: Arc::new(SqliteSyncAccountRepository::new(pool.clone())), | |
| 75 | - | sync_client: std::sync::RwLock::new(None), | |
| 75 | + | sync_client: crate::state::SyncClientCell::default(), | |
| 76 | 76 | sync_lock: Arc::new(tokio::sync::Mutex::new(())), | |
| 77 | 77 | email_sync_locks: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), | |
| 78 | 78 | token_refresh_locks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), |