max / goingson
14 files changed,
+326 insertions,
-73 deletions
| @@ -99,6 +99,15 @@ pub trait TaskRepository: Send + Sync { | |||
| 99 | 99 | /// Updates an existing task. | |
| 100 | 100 | async fn update(&self, id: TaskId, user_id: UserId, task: UpdateTask) -> Result<Option<Task>>; | |
| 101 | 101 | ||
| 102 | + | /// Sets the project for many tasks in one transaction. Returns rows affected. | |
| 103 | + | /// Avoids the per-task fetch+update round-trips the bulk UI did before | |
| 104 | + | /// (ultra-fuzz Run #27 Perf S4). Project does not affect urgency. | |
| 105 | + | async fn bulk_set_project(&self, user_id: UserId, ids: &[TaskId], project_id: Option<ProjectId>) -> Result<usize>; | |
| 106 | + | ||
| 107 | + | /// Sets the priority for many tasks in one transaction, recomputing each task's | |
| 108 | + | /// urgency (priority is an urgency input). Returns rows affected. | |
| 109 | + | async fn bulk_set_priority(&self, user_id: UserId, ids: &[TaskId], priority: crate::models::Priority) -> Result<usize>; | |
| 110 | + | ||
| 102 | 111 | /// Soft-deletes a task. | |
| 103 | 112 | async fn delete(&self, id: TaskId, user_id: UserId) -> Result<bool>; | |
| 104 | 113 | ||
| @@ -316,9 +325,17 @@ pub trait EventRepository: Send + Sync { | |||
| 316 | 325 | /// snoozing, and follow-up tracking. | |
| 317 | 326 | #[async_trait] | |
| 318 | 327 | pub trait EmailRepository: Send + Sync { | |
| 319 | - | /// Lists all emails, optionally including archived. | |
| 328 | + | /// Lists all emails, optionally including archived. Selects full bodies and is | |
| 329 | + | /// unbounded -- intended for backup export, NOT for list views (use | |
| 330 | + | /// [`list_metadata`](Self::list_metadata) there to avoid materializing every | |
| 331 | + | /// body at once). | |
| 320 | 332 | async fn list_all(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>>; | |
| 321 | 333 | ||
| 334 | + | /// Lists emails for a flat list view: body/html_body are omitted (empty) and the | |
| 335 | + | /// result is capped, so a large mailbox doesn't load every body into memory | |
| 336 | + | /// (ultra-fuzz Run #27 Perf S3). Open an email via `get_by_id` for its body. | |
| 337 | + | async fn list_metadata(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>>; | |
| 338 | + | ||
| 322 | 339 | /// Lists emails grouped by thread, with metadata pre-computed and pagination. | |
| 323 | 340 | /// Returns (threads, total_count) sorted by most recent email (newest first). | |
| 324 | 341 | /// Optional `folder` filter restricts to emails from a specific source_folder. |
| @@ -27,6 +27,21 @@ const EMAIL_SELECT_COLUMNS: &str = r#"e.id, e.project_id, p.name as project_name | |||
| 27 | 27 | e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date, | |
| 28 | 28 | e.body_truncated, e.jmap_id"#; | |
| 29 | 29 | ||
| 30 | + | /// Same shape as [`EMAIL_SELECT_COLUMNS`] but with the two heavy body columns | |
| 31 | + | /// blanked, for flat list views that never render a body (Perf S3). `body_truncated` | |
| 32 | + | /// is forced to 1 so the reader knows to re-fetch the full body on open. | |
| 33 | + | const EMAIL_LIST_COLUMNS: &str = r#"e.id, e.project_id, p.name as project_name, e.from_address, e.to_address, | |
| 34 | + | e.subject, '' AS body, NULL AS html_body, e.is_read, e.is_archived, e.received_at, e.message_id, | |
| 35 | + | e.in_reply_to, e.thread_id, e.email_account_id, e.is_outgoing, e.imap_uid, e.source_folder, | |
| 36 | + | e.attachment_meta, e.labels, e.is_draft, e.cc_address, e.bcc_address, e.draft_account_id, | |
| 37 | + | e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date, | |
| 38 | + | 1 AS body_truncated, e.jmap_id"#; | |
| 39 | + | ||
| 40 | + | /// Upper bound on a flat metadata list so it can never materialize an unbounded | |
| 41 | + | /// number of rows (the list UI paginates via `list_threaded`; this flat path is a | |
| 42 | + | /// safety-capped fallback). | |
| 43 | + | const EMAIL_LIST_CAP: i64 = 1000; | |
| 44 | + | ||
| 30 | 45 | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 31 | 46 | struct EmailRow { | |
| 32 | 47 | pub id: String, | |
| @@ -126,6 +141,17 @@ impl EmailRepository for SqliteEmailRepository { | |||
| 126 | 141 | } | |
| 127 | 142 | ||
| 128 | 143 | #[tracing::instrument(skip_all)] | |
| 144 | + | async fn list_metadata(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>> { | |
| 145 | + | let archived_filter = if include_archived { "" } else { "AND e.is_archived = 0" }; | |
| 146 | + | let query = format!( | |
| 147 | + | "SELECT {} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 0 {} ORDER BY e.received_at DESC LIMIT {}", | |
| 148 | + | EMAIL_LIST_COLUMNS, archived_filter, EMAIL_LIST_CAP | |
| 149 | + | ); | |
| 150 | + | let rows = sqlx::query_as::<_, EmailRow>(&query).bind(user_id.to_string()).bind(user_id.to_string()).fetch_all(&self.pool).await.map_err(CoreError::database)?; | |
| 151 | + | rows.into_iter().map(Email::try_from).collect() | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | #[tracing::instrument(skip_all)] | |
| 129 | 155 | async fn list_threaded(&self, user_id: UserId, include_archived: bool, offset: Option<i64>, limit: Option<i64>, folder: Option<&str>, label: Option<&str>) -> Result<(Vec<EmailThread>, i64)> { | |
| 130 | 156 | let uid = user_id.to_string(); | |
| 131 | 157 | let archived_filter = if include_archived { "" } else { "AND e.is_archived = 0" }; |
| @@ -13,9 +13,9 @@ use chrono::{DateTime, NaiveDate, Utc}; | |||
| 13 | 13 | use sqlx::SqlitePool; | |
| 14 | 14 | ||
| 15 | 15 | use goingson_core::{ | |
| 16 | - | AnnotationId, Annotation, ContactId, CoreError, DbValue, MilestoneId, NewTask, ParseableEnum, Priority, | |
| 17 | - | PositiveMinutes, ProjectId, Recurrence, Result, SortDirection, SubtaskId, Subtask, Task, TaskFilterQuery, | |
| 18 | - | TaskId, TaskRepository, TaskSortColumn, TaskStatus, TimeSession, | |
| 16 | + | calculate_urgency, AnnotationId, Annotation, ContactId, CoreError, DbValue, MilestoneId, NewTask, | |
| 17 | + | ParseableEnum, Priority, PositiveMinutes, ProjectId, Recurrence, Result, SortDirection, SubtaskId, | |
| 18 | + | Subtask, Task, TaskFilterQuery, TaskId, TaskRepository, TaskSortColumn, TaskStatus, TimeSession, | |
| 19 | 19 | TimeTrackingSummary, UpdateTask, UserId, | |
| 20 | 20 | }; | |
| 21 | 21 | ||
| @@ -599,6 +599,68 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 599 | 599 | } | |
| 600 | 600 | ||
| 601 | 601 | #[tracing::instrument(skip_all)] | |
| 602 | + | async fn bulk_set_project(&self, user_id: UserId, ids: &[TaskId], project_id: Option<ProjectId>) -> Result<usize> { | |
| 603 | + | if ids.is_empty() { | |
| 604 | + | return Ok(0); | |
| 605 | + | } | |
| 606 | + | let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(","); | |
| 607 | + | let sql = format!( | |
| 608 | + | "UPDATE tasks SET project_id = ? WHERE user_id = ? AND id IN ({placeholders})" | |
| 609 | + | ); | |
| 610 | + | let mut q = sqlx::query(&sql) | |
| 611 | + | .bind(project_id.map(|p| p.to_string())) | |
| 612 | + | .bind(user_id.to_string()); | |
| 613 | + | for id in ids { | |
| 614 | + | q = q.bind(id.to_string()); | |
| 615 | + | } | |
| 616 | + | let result = q.execute(&self.pool).await.map_err(CoreError::database)?; | |
| 617 | + | Ok(result.rows_affected() as usize) | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | #[tracing::instrument(skip_all)] | |
| 621 | + | async fn bulk_set_priority(&self, user_id: UserId, ids: &[TaskId], priority: Priority) -> Result<usize> { | |
| 622 | + | if ids.is_empty() { | |
| 623 | + | return Ok(0); | |
| 624 | + | } | |
| 625 | + | // Priority feeds urgency, so each task's urgency must be recomputed. Do the | |
| 626 | + | // whole batch in one transaction (one connection) instead of N command | |
| 627 | + | // round-trips (Perf S4). | |
| 628 | + | let mut tx = self.pool.begin().await.map_err(CoreError::database)?; | |
| 629 | + | let mut affected = 0usize; | |
| 630 | + | for id in ids { | |
| 631 | + | let row: Option<(String, Option<String>, String, String)> = sqlx::query_as( | |
| 632 | + | "SELECT status, due, created_at, tags FROM tasks WHERE id = ? AND user_id = ?", | |
| 633 | + | ) | |
| 634 | + | .bind(id.to_string()) | |
| 635 | + | .bind(user_id.to_string()) | |
| 636 | + | .fetch_optional(&mut *tx) | |
| 637 | + | .await | |
| 638 | + | .map_err(CoreError::database)?; | |
| 639 | + | let Some((status_s, due_s, created_s, tags_s)) = row else { | |
| 640 | + | continue; | |
| 641 | + | }; | |
| 642 | + | let status = TaskStatus::from_str_or_default(&status_s); | |
| 643 | + | let due = due_s.as_deref().map(parse_datetime).transpose()?; | |
| 644 | + | let created = parse_datetime(&created_s)?; | |
| 645 | + | let tags = parse_tags(&tags_s); | |
| 646 | + | let urgency = calculate_urgency(&priority, &status, due.as_ref(), &created, &tags); | |
| 647 | + | let result = sqlx::query( | |
| 648 | + | "UPDATE tasks SET priority = ?, urgency = ? WHERE id = ? AND user_id = ?", | |
| 649 | + | ) | |
| 650 | + | .bind(priority.db_value()) | |
| 651 | + | .bind(urgency) | |
| 652 | + | .bind(id.to_string()) | |
| 653 | + | .bind(user_id.to_string()) | |
| 654 | + | .execute(&mut *tx) | |
| 655 | + | .await | |
| 656 | + | .map_err(CoreError::database)?; | |
| 657 | + | affected += result.rows_affected() as usize; | |
| 658 | + | } | |
| 659 | + | tx.commit().await.map_err(CoreError::database)?; | |
| 660 | + | Ok(affected) | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | #[tracing::instrument(skip_all)] | |
| 602 | 664 | async fn delete(&self, id: TaskId, user_id: UserId) -> Result<bool> { | |
| 603 | 665 | let result = sqlx::query("UPDATE tasks SET status = 'Deleted' WHERE id = ? AND user_id = ?") | |
| 604 | 666 | .bind(id.to_string()) |
| @@ -3,8 +3,11 @@ | |||
| 3 | 3 | mod common; | |
| 4 | 4 | ||
| 5 | 5 | use chrono::{Duration, Utc}; | |
| 6 | - | use goingson_core::{NewTask, Priority, Recurrence, TaskFilterQuery, TaskRepository, TaskStatus, UpdateTask}; | |
| 7 | - | use goingson_db_sqlite::SqliteTaskRepository; | |
| 6 | + | use goingson_core::{ | |
| 7 | + | NewProject, NewTask, Priority, ProjectRepository, Recurrence, TaskFilterQuery, TaskRepository, | |
| 8 | + | TaskStatus, UpdateTask, | |
| 9 | + | }; | |
| 10 | + | use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository}; | |
| 8 | 11 | ||
| 9 | 12 | #[tokio::test] | |
| 10 | 13 | async fn test_create_and_get_task() { | |
| @@ -472,3 +475,53 @@ async fn list_filtered_clamps_negative_limit() { | |||
| 472 | 475 | assert!(page.is_empty(), "negative limit must not return rows unbounded"); | |
| 473 | 476 | assert_eq!(total, 3, "total count is independent of the clamped limit"); | |
| 474 | 477 | } | |
| 478 | + | ||
| 479 | + | #[tokio::test] | |
| 480 | + | async fn test_bulk_set_priority_recomputes_urgency() { | |
| 481 | + | let pool = common::setup_test_db().await; | |
| 482 | + | let user_id = common::create_test_user(&pool).await; | |
| 483 | + | let repo = SqliteTaskRepository::new(pool); | |
| 484 | + | ||
| 485 | + | let a = repo.create(user_id, NewTask::builder("A").priority(Priority::Low).build()).await.unwrap(); | |
| 486 | + | let b = repo.create(user_id, NewTask::builder("B").priority(Priority::Low).build()).await.unwrap(); | |
| 487 | + | let low_urgency = a.urgency; | |
| 488 | + | ||
| 489 | + | let n = repo.bulk_set_priority(user_id, &[a.id, b.id], Priority::High).await.unwrap(); | |
| 490 | + | assert_eq!(n, 2); | |
| 491 | + | ||
| 492 | + | let a2 = repo.get_by_id(a.id, user_id).await.unwrap().unwrap(); | |
| 493 | + | let b2 = repo.get_by_id(b.id, user_id).await.unwrap().unwrap(); | |
| 494 | + | assert_eq!(a2.priority, Priority::High); | |
| 495 | + | assert_eq!(b2.priority, Priority::High); | |
| 496 | + | assert!(a2.urgency > low_urgency, "urgency recomputed upward for higher priority"); | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | #[tokio::test] | |
| 500 | + | async fn test_bulk_set_project() { | |
| 501 | + | let pool = common::setup_test_db().await; | |
| 502 | + | let user_id = common::create_test_user(&pool).await; | |
| 503 | + | let tasks = SqliteTaskRepository::new(pool.clone()); | |
| 504 | + | let projects = SqliteProjectRepository::new(pool); | |
| 505 | + | ||
| 506 | + | let project = projects | |
| 507 | + | .create(user_id, NewProject { | |
| 508 | + | name: "Bulk".into(), | |
| 509 | + | description: String::new(), | |
| 510 | + | project_type: Default::default(), | |
| 511 | + | status: Default::default(), | |
| 512 | + | }) | |
| 513 | + | .await | |
| 514 | + | .unwrap(); | |
| 515 | + | ||
| 516 | + | let a = tasks.create(user_id, NewTask::builder("A").build()).await.unwrap(); | |
| 517 | + | let b = tasks.create(user_id, NewTask::builder("B").build()).await.unwrap(); | |
| 518 | + | ||
| 519 | + | let n = tasks.bulk_set_project(user_id, &[a.id, b.id], Some(project.id)).await.unwrap(); | |
| 520 | + | assert_eq!(n, 2); | |
| 521 | + | assert_eq!(tasks.get_by_id(a.id, user_id).await.unwrap().unwrap().project_id, Some(project.id)); | |
| 522 | + | ||
| 523 | + | // Clearing the project (None) also works. | |
| 524 | + | let n2 = tasks.bulk_set_project(user_id, &[a.id], None).await.unwrap(); | |
| 525 | + | assert_eq!(n2, 1); | |
| 526 | + | assert_eq!(tasks.get_by_id(a.id, user_id).await.unwrap().unwrap().project_id, None); | |
| 527 | + | } |
| @@ -40,13 +40,28 @@ async function invoke(command, args = {}) { | |||
| 40 | 40 | throw new Error('Tauri not available'); | |
| 41 | 41 | } | |
| 42 | 42 | try { | |
| 43 | - | return await tauriInvoke(command, args); | |
| 43 | + | const result = await tauriInvoke(command, args); | |
| 44 | + | // Mark JS-initiated writes so the DB file-watcher can ignore its own echo | |
| 45 | + | // (CHRONIC-E): a successful mutation already refreshed the UI, so the | |
| 46 | + | // db:external-change it triggers is a redundant invalidate-everything storm. | |
| 47 | + | // Reads never mark (so a background-sync write the JS didn't initiate still | |
| 48 | + | // refreshes via db:external-change). Conservative prefix match: only commands | |
| 49 | + | // we are confident write the DB mark. | |
| 50 | + | if (MUTATION_COMMAND.test(command)) { | |
| 51 | + | window.__goLastLocalWriteAt = Date.now(); | |
| 52 | + | } | |
| 53 | + | return result; | |
| 44 | 54 | } catch (err) { | |
| 45 | 55 | console.error(`[api] invoke '${command}' failed:`, err, 'args:', JSON.stringify(args)); | |
| 46 | 56 | throw err; | |
| 47 | 57 | } | |
| 48 | 58 | } | |
| 49 | 59 | ||
| 60 | + | /// Commands that write the local DB. Matching this marks a local write for the | |
| 61 | + | /// file-watcher echo-suppression (CHRONIC-E). Read commands (list_/get_/search/ | |
| 62 | + | /// validate) deliberately do not match. | |
| 63 | + | const MUTATION_COMMAND = /^(create|update|delete|bulk|complete|start|snooze|unsnooze|mark|clear|archive|unarchive|set|toggle|upsert|add|remove|move|promote|convert|reorder|save|restore|import|sync|log|schedule|reanchor|link|unlink)_/; | |
| 64 | + | ||
| 50 | 65 | // ============ API Object ============ | |
| 51 | 66 | ||
| 52 | 67 | const api = { | |
| @@ -70,6 +85,8 @@ const api = { | |||
| 70 | 85 | quickAdd: (text) => invoke('quick_add_task', { input: { text } }), // Natural language: "Fix bug +work @tomorrow !high" | |
| 71 | 86 | update: (id, input) => invoke('update_task', { id, input }), | |
| 72 | 87 | delete: (id) => invoke('delete_task', { id }), | |
| 88 | + | bulkSetProject: (ids, projectId) => invoke('bulk_set_task_project', { ids, projectId }), | |
| 89 | + | bulkSetPriority: (ids, priority) => invoke('bulk_set_task_priority', { ids, priority }), | |
| 73 | 90 | start: (id) => invoke('start_task', { id }), // Pending → Started (sets started_at) | |
| 74 | 91 | complete: (id) => invoke('complete_task', { id }), // Spawns next instance for recurring tasks | |
| 75 | 92 | listSnoozed: () => invoke('list_snoozed_tasks'), |
| @@ -6,6 +6,12 @@ | |||
| 6 | 6 | (function() { | |
| 7 | 7 | 'use strict'; | |
| 8 | 8 | ||
| 9 | + | // How long after a JS-initiated DB write to treat a db:external-change event as the | |
| 10 | + | // app's own echo and skip it (CHRONIC-E). Comfortably covers the watcher's debounce | |
| 11 | + | // (it rate-limits to ~1/sec plus a trailing emit) without masking a genuinely | |
| 12 | + | // external write that arrives later. | |
| 13 | + | const LOCAL_WRITE_SUPPRESS_MS = 2500; | |
| 14 | + | ||
| 9 | 15 | // Maps a synced DB table (from the `sync:changes-applied` payload) to the view | |
| 10 | 16 | // cache entities it affects. A table mapping to `[]` has no cached view to bust | |
| 11 | 17 | // (the re-render alone refetches it). Any table NOT present here triggers a full | |
| @@ -165,8 +171,19 @@ if (window.__TAURI__) { | |||
| 165 | 171 | listen('menu:keyboard_shortcuts', () => GoingsOn.keyboard.toggleShortcuts()); | |
| 166 | 172 | listen('menu:about', () => GoingsOn.app.openAboutModal()); | |
| 167 | 173 | ||
| 168 | - | // Database external change detection | |
| 174 | + | // Database external change detection. | |
| 175 | + | // | |
| 176 | + | // The watcher fires on ANY change to the DB file, including this app's own | |
| 177 | + | // writes. A JS-initiated mutation already refreshed the UI, so the echo here | |
| 178 | + | // would be a redundant invalidate-everything storm (CHRONIC-E). Skip it when a | |
| 179 | + | // local write happened in the last LOCAL_WRITE_SUPPRESS_MS. A genuinely external | |
| 180 | + | // write (e.g. the background email-sync scheduler, which runs in Rust and never | |
| 181 | + | // marks a JS write) is NOT recent-marked, so it still refreshes. | |
| 169 | 182 | listen('db:external-change', () => { | |
| 183 | + | const sinceLocalWrite = Date.now() - (window.__goLastLocalWriteAt || 0); | |
| 184 | + | if (sinceLocalWrite < LOCAL_WRITE_SUPPRESS_MS) { | |
| 185 | + | return; | |
| 186 | + | } | |
| 170 | 187 | GoingsOn.cache.invalidateAll(); | |
| 171 | 188 | refreshCurrentViewData(); | |
| 172 | 189 | }); | |
| @@ -195,6 +212,9 @@ if (window.__TAURI__) { | |||
| 195 | 212 | GoingsOn.cache.invalidate(...entities); | |
| 196 | 213 | } | |
| 197 | 214 | } | |
| 215 | + | // Cloud sync already refreshed selectively here; mark it so the redundant | |
| 216 | + | // db:external-change echo from the same writes is suppressed (CHRONIC-E). | |
| 217 | + | window.__goLastLocalWriteAt = Date.now(); | |
| 198 | 218 | refreshCurrentViewData(); | |
| 199 | 219 | }); | |
| 200 | 220 |
| @@ -266,30 +266,18 @@ | |||
| 266 | 266 | )); | |
| 267 | 267 | }, | |
| 268 | 268 | commit: async (ids) => { | |
| 269 | - | const results = await Promise.allSettled(ids.map(async (id) => { | |
| 270 | - | const task = await GoingsOn.api.tasks.get(id); | |
| 271 | - | if (!task) return; | |
| 272 | - | return GoingsOn.api.tasks.update(id, { | |
| 273 | - | description: task.description, | |
| 274 | - | priority: field === 'priority' ? newValue : task.priority, | |
| 275 | - | status: task.status, | |
| 276 | - | projectId: field === 'projectId' ? newValue : task.projectId, | |
| 277 | - | due: task.due, | |
| 278 | - | tags: task.tags, | |
| 279 | - | recurrence: task.recurrence, | |
| 280 | - | estimatedMinutes: task.estimatedMinutes, | |
| 281 | - | contactId: task.contactId, | |
| 282 | - | milestoneId: task.milestoneId, | |
| 283 | - | }); | |
| 284 | - | })); | |
| 285 | - | const failed = results.filter(r => r.status === 'rejected').length; | |
| 286 | - | if (failed === ids.length) { | |
| 287 | - | throw results.find(r => r.status === 'rejected').reason; | |
| 288 | - | } | |
| 289 | - | if (failed > 0) { | |
| 290 | - | GoingsOn.ui.showToast(`${ids.length - failed} updated, ${failed} failed`, 'warning'); | |
| 269 | + | // One batched, transactional command instead of the old 2N | |
| 270 | + | // get+update round-trips (Perf S4). The optimistic apply() above | |
| 271 | + | // already updated the visible rows in place. | |
| 272 | + | if (field === 'projectId') { | |
| 273 | + | await GoingsOn.api.tasks.bulkSetProject(ids, newValue); | |
| 274 | + | // Project doesn't affect urgency/sort, so the in-place update stands. | |
| 275 | + | } else if (field === 'priority') { | |
| 276 | + | await GoingsOn.api.tasks.bulkSetPriority(ids, newValue); | |
| 277 | + | // Priority feeds urgency, which is the sort key — one refetch | |
| 278 | + | // re-sorts the list (one round-trip, not 2N). | |
| 279 | + | GoingsOn.tasks.load(); | |
| 291 | 280 | } | |
| 292 | - | GoingsOn.tasks.load(); | |
| 293 | 281 | }, | |
| 294 | 282 | errorMessage: `Failed to update ${labelNoun}`, | |
| 295 | 283 | }); |
| @@ -87,6 +87,9 @@ | |||
| 87 | 87 | writeFiltersToUrl(); | |
| 88 | 88 | GoingsOn.tasks.pagination.reset(); | |
| 89 | 89 | populateMilestoneFilter(); | |
| 90 | + | // load() no-ops while the tasks cache is fresh (30s); a filter/sort change | |
| 91 | + | // must refetch with the new criteria, so invalidate first (Perf M3). | |
| 92 | + | GoingsOn.cache.invalidate('tasks'); | |
| 90 | 93 | GoingsOn.tasks.load(); | |
| 91 | 94 | } | |
| 92 | 95 | ||
| @@ -104,6 +107,7 @@ | |||
| 104 | 107 | clearSelectionIfAny(); | |
| 105 | 108 | GoingsOn.queryState?.clear(['status', 'project', 'priority', 'milestone', 'snoozed', 'waiting']); | |
| 106 | 109 | GoingsOn.tasks.pagination.reset(); | |
| 110 | + | GoingsOn.cache.invalidate('tasks'); // refetch with cleared filters (Perf M3) | |
| 107 | 111 | GoingsOn.tasks.load(); | |
| 108 | 112 | } | |
| 109 | 113 |
| @@ -256,15 +256,24 @@ pub async fn create_backup_now( | |||
| 256 | 256 | .map_err(|e| format!("Failed to get app data dir: {}", e))? | |
| 257 | 257 | .join("backups"); | |
| 258 | 258 | ||
| 259 | - | std::fs::create_dir_all(&backup_dir) | |
| 260 | - | .map_err(|e| format!("Failed to create backup directory: {}", e))?; | |
| 261 | - | ||
| 262 | 259 | let filename = backup_filename(now); | |
| 263 | 260 | let file_path = backup_dir.join(&filename); | |
| 264 | 261 | ||
| 265 | 262 | let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?; | |
| 266 | 263 | let item_count = export.total_count(); | |
| 267 | - | let size_bytes = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?; | |
| 264 | + | ||
| 265 | + | // Directory creation + gzip serialization are blocking and take seconds on a | |
| 266 | + | // large DB; run them on the blocking pool so the manual "Backup now" action | |
| 267 | + | // doesn't freeze the UI (the scheduled path already does this — Perf S6). | |
| 268 | + | let backup_dir_task = backup_dir.clone(); | |
| 269 | + | let file_path_task = file_path.clone(); | |
| 270 | + | let size_bytes = tokio::task::spawn_blocking(move || -> Result<u64, String> { | |
| 271 | + | std::fs::create_dir_all(&backup_dir_task) | |
| 272 | + | .map_err(|e| format!("Failed to create backup directory: {}", e))?; | |
| 273 | + | write_backup(&export, &file_path_task).map_err(|e| format!("Failed to write backup: {}", e)) | |
| 274 | + | }) | |
| 275 | + | .await | |
| 276 | + | .map_err(|e| format!("Backup task panicked: {}", e))??; | |
| 268 | 277 | ||
| 269 | 278 | // Update last backup timestamp | |
| 270 | 279 | state |
| @@ -91,11 +91,14 @@ pub async fn reconcile(pool: &SqlitePool, data_dir: &Path) { | |||
| 91 | 91 | } | |
| 92 | 92 | }; | |
| 93 | 93 | ||
| 94 | + | // read_dir + remove_file are blocking; run on the blocking pool so the | |
| 95 | + | // startup GC doesn't stall the reactor / delay first paint (Perf M4). | |
| 94 | 96 | let blobs_dir = data_dir.join("blobs"); | |
| 95 | - | match reclaim_orphans(&blobs_dir, &referenced) { | |
| 96 | - | Ok(0) => {} | |
| 97 | - | Ok(n) => info!("Blob GC reclaimed {n} orphaned blob(s)"), | |
| 98 | - | Err(e) => warn!("Blob GC: could not scan blob dir: {e}"), | |
| 97 | + | match tokio::task::spawn_blocking(move || reclaim_orphans(&blobs_dir, &referenced)).await { | |
| 98 | + | Ok(Ok(0)) => {} | |
| 99 | + | Ok(Ok(n)) => info!("Blob GC reclaimed {n} orphaned blob(s)"), | |
| 100 | + | Ok(Err(e)) => warn!("Blob GC: could not scan blob dir: {e}"), | |
| 101 | + | Err(e) => warn!("Blob GC task panicked: {e}"), | |
| 99 | 102 | } | |
| 100 | 103 | } | |
| 101 | 104 |
| @@ -112,29 +112,34 @@ pub async fn add_attachment( | |||
| 112 | 112 | ))); | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | - | // Read file and compute SHA-256 | |
| 116 | - | let file_data = std::fs::read(source_path) | |
| 117 | - | .map_api_err("Failed to read file", ApiError::internal)?; | |
| 118 | - | ||
| 119 | - | let hash = { | |
| 120 | - | let mut hasher = Sha256::new(); | |
| 121 | - | hasher.update(&file_data); | |
| 122 | - | format!("{:x}", hasher.finalize()) | |
| 123 | - | }; | |
| 124 | - | ||
| 125 | - | // Ensure blobs directory exists | |
| 115 | + | // Read (up to MAX_FILE_SIZE), hash, and write the blob on the blocking pool so a | |
| 116 | + | // large attachment doesn't stall the async reactor (ultra-fuzz Run #27 Perf S1). | |
| 117 | + | let source_owned = source_path.to_path_buf(); | |
| 126 | 118 | let blobs_dir = state.data_dir.join("blobs"); | |
| 127 | - | std::fs::create_dir_all(&blobs_dir) | |
| 128 | - | .map_api_err("Failed to create blobs directory", ApiError::internal)?; | |
| 129 | - | ||
| 130 | - | // Copy to blob store (skip if hash already exists — dedup). Track whether we | |
| 131 | - | // were the writer so a failed insert can roll back exactly the blob we added. | |
| 132 | - | let blob_path = blobs_dir.join(&hash); | |
| 133 | - | let wrote_new_blob = !blob_path.exists(); | |
| 134 | - | if wrote_new_blob { | |
| 135 | - | std::fs::write(&blob_path, &file_data) | |
| 136 | - | .map_api_err("Failed to write blob", ApiError::internal)?; | |
| 137 | - | } | |
| 119 | + | let (hash, file_size, wrote_new_blob, blob_path) = tokio::task::spawn_blocking( | |
| 120 | + | move || -> Result<(String, i64, bool, std::path::PathBuf), String> { | |
| 121 | + | let file_data = std::fs::read(&source_owned).map_err(|e| format!("Failed to read file: {e}"))?; | |
| 122 | + | let file_size = file_data.len() as i64; | |
| 123 | + | let hash = { | |
| 124 | + | let mut hasher = Sha256::new(); | |
| 125 | + | hasher.update(&file_data); | |
| 126 | + | format!("{:x}", hasher.finalize()) | |
| 127 | + | }; | |
| 128 | + | std::fs::create_dir_all(&blobs_dir) | |
| 129 | + | .map_err(|e| format!("Failed to create blobs directory: {e}"))?; | |
| 130 | + | // Copy to blob store (skip if hash already exists — dedup). Track whether | |
| 131 | + | // we wrote it so a failed insert can roll back exactly the blob we added. | |
| 132 | + | let blob_path = blobs_dir.join(&hash); | |
| 133 | + | let wrote_new_blob = !blob_path.exists(); | |
| 134 | + | if wrote_new_blob { | |
| 135 | + | std::fs::write(&blob_path, &file_data).map_err(|e| format!("Failed to write blob: {e}"))?; | |
| 136 | + | } | |
| 137 | + | Ok((hash, file_size, wrote_new_blob, blob_path)) | |
| 138 | + | }, | |
| 139 | + | ) | |
| 140 | + | .await | |
| 141 | + | .map_err(|e| ApiError::internal(format!("Attachment task panicked: {e}")))? | |
| 142 | + | .map_err(ApiError::internal)?; | |
| 138 | 143 | ||
| 139 | 144 | // Extract filename from path | |
| 140 | 145 | let filename = source_path | |
| @@ -144,7 +149,6 @@ pub async fn add_attachment( | |||
| 144 | 149 | .to_string(); | |
| 145 | 150 | ||
| 146 | 151 | let mime_type = mime_from_extension(&filename).to_string(); | |
| 147 | - | let file_size = file_data.len() as i64; | |
| 148 | 152 | ||
| 149 | 153 | let create_result = state.attachments | |
| 150 | 154 | .create(DESKTOP_USER_ID, NewAttachment { | |
| @@ -245,9 +249,9 @@ pub async fn open_attachment( | |||
| 245 | 249 | .collect(); | |
| 246 | 250 | let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name }; | |
| 247 | 251 | let temp_path = temp_dir.join(&safe_name); | |
| 248 | - | // Copy blob to temp with original filename (overwrite if exists) | |
| 249 | - | std::fs::copy(&blob_path, &temp_path) | |
| 250 | - | .map_api_err("Failed to prepare file for opening", ApiError::internal)?; | |
| 252 | + | // Copy blob to temp with original filename (overwrite if exists). On the blocking | |
| 253 | + | // pool — a large attachment copy must not stall the reactor (Perf S1). | |
| 254 | + | copy_blocking(blob_path, temp_path.clone()).await?; | |
| 251 | 255 | ||
| 252 | 256 | open::that(&temp_path) | |
| 253 | 257 | .map_api_err("Failed to open file", ApiError::internal)?; | |
| @@ -276,8 +280,7 @@ pub async fn save_attachment( | |||
| 276 | 280 | return Err(ApiError::bad_request("Blob not available locally — sync required")); | |
| 277 | 281 | } | |
| 278 | 282 | ||
| 279 | - | std::fs::copy(&blob_path, &destination) | |
| 280 | - | .map_api_err("Failed to save file", ApiError::internal)?; | |
| 283 | + | copy_blocking(blob_path, PathBuf::from(&destination)).await?; | |
| 281 | 284 | ||
| 282 | 285 | Ok(()) | |
| 283 | 286 | } | |
| @@ -397,8 +400,7 @@ pub async fn open_email_blob( | |||
| 397 | 400 | let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name }; | |
| 398 | 401 | let temp_path = temp_dir.join(&safe_name); | |
| 399 | 402 | ||
| 400 | - | std::fs::copy(&blob_path, &temp_path) | |
| 401 | - | .map_api_err("Failed to prepare file for opening", ApiError::internal)?; | |
| 403 | + | copy_blocking(blob_path, temp_path.clone()).await?; | |
| 402 | 404 | ||
| 403 | 405 | open::that(&temp_path) | |
| 404 | 406 | .map_api_err("Failed to open file", ApiError::internal)?; | |
| @@ -422,8 +424,7 @@ pub async fn save_email_blob( | |||
| 422 | 424 | return Err(ApiError::bad_request("Attachment not available locally — sync required")); | |
| 423 | 425 | } | |
| 424 | 426 | ||
| 425 | - | std::fs::copy(&blob_path, &destination) | |
| 426 | - | .map_api_err("Failed to save file", ApiError::internal)?; | |
| 427 | + | copy_blocking(blob_path, PathBuf::from(&destination)).await?; | |
| 427 | 428 | ||
| 428 | 429 | Ok(()) | |
| 429 | 430 | } | |
| @@ -443,6 +444,16 @@ pub(crate) fn is_valid_blob_hash(hash: &str) -> bool { | |||
| 443 | 444 | .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) | |
| 444 | 445 | } | |
| 445 | 446 | ||
| 447 | + | /// Copy a file on the blocking pool so a large blob copy doesn't stall the async | |
| 448 | + | /// reactor (ultra-fuzz Run #27 Perf S1). | |
| 449 | + | async fn copy_blocking(from: PathBuf, to: PathBuf) -> Result<(), ApiError> { | |
| 450 | + | tokio::task::spawn_blocking(move || std::fs::copy(&from, &to)) | |
| 451 | + | .await | |
| 452 | + | .map_err(|e| ApiError::internal(format!("Copy task panicked: {e}")))? | |
| 453 | + | .map_err(|e| ApiError::internal(format!("Failed to copy file: {e}")))?; | |
| 454 | + | Ok(()) | |
| 455 | + | } | |
| 456 | + | ||
| 446 | 457 | fn to_response(a: goingson_core::Attachment, data_dir: &Path) -> AttachmentResponse { | |
| 447 | 458 | let has_local_blob = data_dir.join("blobs").join(&a.blob_hash).exists(); | |
| 448 | 459 | AttachmentResponse { |
| @@ -296,7 +296,9 @@ pub struct SendEmailResponse { | |||
| 296 | 296 | #[tauri::command] | |
| 297 | 297 | #[instrument(skip_all)] | |
| 298 | 298 | pub async fn list_emails(state: State<'_, Arc<AppState>>, include_archived: Option<bool>) -> Result<Vec<EmailResponse>, ApiError> { | |
| 299 | - | let emails = state.emails.list_all(DESKTOP_USER_ID, include_archived.unwrap_or(false)).await?; | |
| 299 | + | // Body-less + capped: this flat list never renders a body (the reader fetches it | |
| 300 | + | // via get_by_id), so it must not materialize every body at once (Perf S3). | |
| 301 | + | let emails = state.emails.list_metadata(DESKTOP_USER_ID, include_archived.unwrap_or(false)).await?; | |
| 300 | 302 | Ok(emails.into_iter().map(EmailResponse::from).collect()) | |
| 301 | 303 | } | |
| 302 | 304 | ||
| @@ -620,14 +622,27 @@ async fn send_email_inner(state: &Arc<AppState>, input: SendEmailInput) -> Resul | |||
| 620 | 622 | .await? | |
| 621 | 623 | .or_not_found("emailAccount", input.account_id)?; | |
| 622 | 624 | ||
| 623 | - | // Read attachment files | |
| 625 | + | // Read attachment files. Cap the combined size before buffering them all into | |
| 626 | + | // memory (and base64-encoding ~1.33x on top) so a huge selection can't blow up | |
| 627 | + | // memory or get rejected by the server after a long upload (Perf minor). | |
| 628 | + | const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; | |
| 624 | 629 | use crate::email::smtp_client::AttachmentFile; | |
| 625 | 630 | let mut attachment_files = Vec::new(); | |
| 631 | + | let mut total_bytes: u64 = 0; | |
| 626 | 632 | for path_str in &input.attachment_paths { | |
| 627 | 633 | let path = std::path::Path::new(path_str); | |
| 628 | 634 | if !path.is_file() { | |
| 629 | 635 | return Err(ApiError::validation("attachmentPaths", format!("File not found: {}", path_str))); | |
| 630 | 636 | } | |
| 637 | + | let meta = tokio::fs::metadata(path).await | |
| 638 | + | .map_api_err("Failed to read attachment file", ApiError::internal)?; | |
| 639 | + | total_bytes = total_bytes.saturating_add(meta.len()); | |
| 640 | + | if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES { | |
| 641 | + | return Err(ApiError::validation( | |
| 642 | + | "attachmentPaths", | |
| 643 | + | format!("Attachments exceed the {} MB total limit", MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)), | |
| 644 | + | )); | |
| 645 | + | } | |
| 631 | 646 | let data = tokio::fs::read(path).await | |
| 632 | 647 | .map_api_err("Failed to read attachment file", ApiError::internal)?; | |
| 633 | 648 | let filename = path.file_name() |
| @@ -517,6 +517,32 @@ pub async fn delete_task(state: State<'_, Arc<AppState>>, id: TaskId) -> Result< | |||
| 517 | 517 | Ok(state.tasks.delete(id, DESKTOP_USER_ID).await?) | |
| 518 | 518 | } | |
| 519 | 519 | ||
| 520 | + | /// Sets the project for many tasks at once. Returns the number updated. | |
| 521 | + | /// | |
| 522 | + | /// One transaction instead of the per-task fetch+update round-trips the bulk UI | |
| 523 | + | /// used to do (ultra-fuzz Run #27 Perf S4). | |
| 524 | + | #[tauri::command] | |
| 525 | + | #[instrument(skip_all)] | |
| 526 | + | pub async fn bulk_set_task_project( | |
| 527 | + | state: State<'_, Arc<AppState>>, | |
| 528 | + | ids: Vec<TaskId>, | |
| 529 | + | project_id: Option<ProjectId>, | |
| 530 | + | ) -> Result<usize, ApiError> { | |
| 531 | + | Ok(state.tasks.bulk_set_project(DESKTOP_USER_ID, &ids, project_id).await?) | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | /// Sets the priority for many tasks at once (urgency is recomputed per task). | |
| 535 | + | /// Returns the number updated. | |
| 536 | + | #[tauri::command] | |
| 537 | + | #[instrument(skip_all)] | |
| 538 | + | pub async fn bulk_set_task_priority( | |
| 539 | + | state: State<'_, Arc<AppState>>, | |
| 540 | + | ids: Vec<TaskId>, | |
| 541 | + | priority: Priority, | |
| 542 | + | ) -> Result<usize, ApiError> { | |
| 543 | + | Ok(state.tasks.bulk_set_priority(DESKTOP_USER_ID, &ids, priority).await?) | |
| 544 | + | } | |
| 545 | + | ||
| 520 | 546 | /// Marks a task as started (in progress). | |
| 521 | 547 | /// | |
| 522 | 548 | /// Only works for tasks in Pending status. |
| @@ -66,6 +66,8 @@ macro_rules! all_commands { | |||
| 66 | 66 | $crate::commands::quick_add_task, | |
| 67 | 67 | $crate::commands::update_task, | |
| 68 | 68 | $crate::commands::delete_task, | |
| 69 | + | $crate::commands::bulk_set_task_project, | |
| 70 | + | $crate::commands::bulk_set_task_priority, | |
| 69 | 71 | $crate::commands::start_task, | |
| 70 | 72 | $crate::commands::complete_task, | |
| 71 | 73 | $crate::commands::list_snoozed_tasks, |