max / goingson
13 files changed,
+283 insertions,
-59 deletions
| @@ -72,7 +72,7 @@ pub use models::{ | |||
| 72 | 72 | Project, ParseableEnum, ProjectStatus, ProjectType, Recurrence, RecurrenceRule, MonthlySpec, | |
| 73 | 73 | SavedView, SortDirection, | |
| 74 | 74 | SyncAccount, | |
| 75 | - | SortField, Subtask, Task, TaskFilterQuery, TaskSortColumn, TaskStatus, TimeSession, | |
| 75 | + | PositiveMinutes, SortField, Subtask, Task, TaskFilterQuery, TaskSortColumn, TaskStatus, TimeSession, | |
| 76 | 76 | TimeTrackingSummary, UpdateEvent, UpdateProject, UpdateTask, User, | |
| 77 | 77 | ViewFilters, ViewType, WeeklyReview, | |
| 78 | 78 | format_file_size, mime_from_extension, |
| @@ -4,10 +4,40 @@ | |||
| 4 | 4 | //! when a timer is started and closed when it's stopped. At most one session | |
| 5 | 5 | //! per user can be active (ended_at IS NULL) at any time. | |
| 6 | 6 | ||
| 7 | + | use std::num::NonZeroU32; | |
| 8 | + | ||
| 7 | 9 | use chrono::{DateTime, Utc}; | |
| 8 | 10 | use serde::{Deserialize, Serialize}; | |
| 11 | + | use crate::error::CoreError; | |
| 9 | 12 | use crate::id_types::{TaskId, TimeSessionId, UserId, ProjectId}; | |
| 10 | 13 | ||
| 14 | + | /// A validated, strictly-positive minute count for a manual time entry. | |
| 15 | + | /// | |
| 16 | + | /// Constructing this is the single gate that rejects zero/negative durations: | |
| 17 | + | /// a negative value would make `ended_at < started_at` and drive a task's | |
| 18 | + | /// cached `actual_minutes` negative, and zero records an empty session. Because | |
| 19 | + | /// the repository takes `PositiveMinutes` (not a raw `i32`), that whole class is | |
| 20 | + | /// unrepresentable below the command layer. | |
| 21 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 22 | + | pub struct PositiveMinutes(NonZeroU32); | |
| 23 | + | ||
| 24 | + | impl PositiveMinutes { | |
| 25 | + | /// Validate a raw minute count. Returns a `Validation` error for anything | |
| 26 | + | /// less than 1 (including negatives, which fail the `u32` conversion). | |
| 27 | + | pub fn try_new(minutes: i32) -> crate::Result<Self> { | |
| 28 | + | u32::try_from(minutes) | |
| 29 | + | .ok() | |
| 30 | + | .and_then(NonZeroU32::new) | |
| 31 | + | .map(Self) | |
| 32 | + | .ok_or_else(|| CoreError::validation("minutes", "Minutes must be at least 1")) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// The validated count as an `i32` for binding into SQL. | |
| 36 | + | pub fn as_i32(self) -> i32 { | |
| 37 | + | self.0.get() as i32 | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 11 | 41 | /// A single time tracking session on a task. | |
| 12 | 42 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 13 | 43 | #[serde(rename_all = "camelCase")] | |
| @@ -90,4 +120,17 @@ mod tests { | |||
| 90 | 120 | let session = make_session(start, None); | |
| 91 | 121 | assert!(session.elapsed_minutes() >= 9); | |
| 92 | 122 | } | |
| 123 | + | ||
| 124 | + | #[test] | |
| 125 | + | fn positive_minutes_accepts_positive_values() { | |
| 126 | + | assert_eq!(PositiveMinutes::try_new(1).unwrap().as_i32(), 1); | |
| 127 | + | assert_eq!(PositiveMinutes::try_new(30).unwrap().as_i32(), 30); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn positive_minutes_rejects_zero_and_negatives() { | |
| 132 | + | assert!(PositiveMinutes::try_new(0).is_err()); | |
| 133 | + | assert!(PositiveMinutes::try_new(-1).is_err()); | |
| 134 | + | assert!(PositiveMinutes::try_new(-30).is_err()); | |
| 135 | + | } | |
| 93 | 136 | } |
| @@ -21,7 +21,7 @@ use crate::error::CoreError; | |||
| 21 | 21 | use crate::models::{ | |
| 22 | 22 | Annotation, Attachment, Email, EmailAccount, EmailAuthType, EmailThread, Event, | |
| 23 | 23 | FolderSyncState, NewAttachment, NewEmail, NewEmailWithTracking, NewEvent, NewProject, | |
| 24 | - | NewSavedView, NewTask, Project, SavedView, Subtask, Task, TaskFilterQuery, TimeSession, | |
| 24 | + | NewSavedView, NewTask, PositiveMinutes, Project, SavedView, Subtask, Task, TaskFilterQuery, TimeSession, | |
| 25 | 25 | TimeTrackingSummary, UpdateTask, User, | |
| 26 | 26 | }; | |
| 27 | 27 | ||
| @@ -226,7 +226,10 @@ pub trait TaskRepository: Send + Sync { | |||
| 226 | 226 | async fn list_all_time_sessions(&self, user_id: UserId) -> Result<Vec<TimeSession>>; | |
| 227 | 227 | ||
| 228 | 228 | /// Logs a manual time entry (retroactive, no live timer). | |
| 229 | - | async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: i32, date: DateTime<Utc>) -> Result<TimeSession>; | |
| 229 | + | /// | |
| 230 | + | /// `minutes` is a validated [`PositiveMinutes`], so the repository cannot be | |
| 231 | + | /// handed a zero/negative duration. | |
| 232 | + | async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: PositiveMinutes, date: DateTime<Utc>) -> Result<TimeSession>; | |
| 230 | 233 | ||
| 231 | 234 | /// Gets aggregated time tracking summary grouped by project and date. | |
| 232 | 235 | async fn get_time_summary(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<TimeTrackingSummary>>; |
| @@ -41,12 +41,17 @@ pub async fn migrate_deterministic_email_ids(pool: &SqlitePool) -> Result<(), St | |||
| 41 | 41 | ||
| 42 | 42 | let mut updated = 0u64; | |
| 43 | 43 | ||
| 44 | - | // Use a dedicated connection with FK enforcement off, wrapped in a transaction | |
| 44 | + | // Use a dedicated connection with FK enforcement off, wrapped in a transaction. | |
| 45 | + | // `detach()` removes it from the pool: `PRAGMA foreign_keys` is per-connection, | |
| 46 | + | // so any early-return between here and the re-enable below must NOT hand a | |
| 47 | + | // FK-disabled connection back to the pool. A detached connection is simply | |
| 48 | + | // closed on drop instead. | |
| 45 | 49 | let mut conn = pool.acquire().await | |
| 46 | - | .map_err(|e| format!("Failed to acquire connection: {e}"))?; | |
| 50 | + | .map_err(|e| format!("Failed to acquire connection: {e}"))? | |
| 51 | + | .detach(); | |
| 47 | 52 | ||
| 48 | 53 | sqlx::query("PRAGMA foreign_keys = OFF") | |
| 49 | - | .execute(&mut *conn) | |
| 54 | + | .execute(&mut conn) | |
| 50 | 55 | .await | |
| 51 | 56 | .map_err(|e| format!("Failed to disable FK: {e}"))?; | |
| 52 | 57 | ||
| @@ -95,7 +100,7 @@ pub async fn migrate_deterministic_email_ids(pool: &SqlitePool) -> Result<(), St | |||
| 95 | 100 | ||
| 96 | 101 | // Re-enable FK enforcement on the same connection | |
| 97 | 102 | sqlx::query("PRAGMA foreign_keys = ON") | |
| 98 | - | .execute(&mut *conn) | |
| 103 | + | .execute(&mut conn) | |
| 99 | 104 | .await | |
| 100 | 105 | .map_err(|e| format!("Failed to re-enable FK: {e}"))?; | |
| 101 | 106 |
| @@ -122,7 +122,12 @@ impl SearchRepository for SqliteSearchRepository { | |||
| 122 | 122 | .unwrap_or(std::cmp::Ordering::Equal) | |
| 123 | 123 | }); | |
| 124 | 124 | ||
| 125 | - | // Capture total before pagination (approximate — sum of capped per-type counts) | |
| 125 | + | // Total is a LOWER BOUND, not an exact match count: each per-type query | |
| 126 | + | // is capped at `per_type_cap` (offset + limit), so a type that saturates | |
| 127 | + | // its cap contributes only that many rows here. This is deliberate — | |
| 128 | + | // computing an exact total would mean five extra COUNT queries on every | |
| 129 | + | // keystroke-driven search. Callers use it only to decide whether another | |
| 130 | + | // page exists, for which a lower bound of `> offset + limit` is correct. | |
| 126 | 131 | let total = results.len(); | |
| 127 | 132 | ||
| 128 | 133 | // Apply pagination |
| @@ -14,7 +14,7 @@ use sqlx::SqlitePool; | |||
| 14 | 14 | ||
| 15 | 15 | use goingson_core::{ | |
| 16 | 16 | AnnotationId, Annotation, ContactId, CoreError, DbValue, MilestoneId, NewTask, ParseableEnum, Priority, | |
| 17 | - | ProjectId, Recurrence, Result, SortDirection, SubtaskId, Subtask, Task, TaskFilterQuery, | |
| 17 | + | PositiveMinutes, ProjectId, Recurrence, Result, SortDirection, SubtaskId, Subtask, Task, TaskFilterQuery, | |
| 18 | 18 | TaskId, TaskRepository, TaskSortColumn, TaskStatus, TimeSession, | |
| 19 | 19 | TimeTrackingSummary, UpdateTask, UserId, | |
| 20 | 20 | }; | |
| @@ -550,18 +550,18 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 550 | 550 | let scheduled_start_str = format_datetime_opt(task.scheduled_start); | |
| 551 | 551 | let tags_json = serde_json::to_string(&task.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 552 | 552 | ||
| 553 | - | // Set completed_at when transitioning to Completed, clear when leaving Completed. | |
| 554 | - | // Uses lightweight query (no annotation/subtask/session sub-queries). | |
| 555 | - | let completed_at_str: Option<String> = if task.status == TaskStatus::Completed { | |
| 556 | - | let ctx = get_task_update_context(&self.pool, id, user_id).await?; | |
| 557 | - | match ctx { | |
| 558 | - | Some(ref c) if c.status == TaskStatus::Completed => { | |
| 559 | - | c.completed_at.as_ref().map(format_datetime) | |
| 560 | - | } | |
| 561 | - | _ => Some(format_datetime_now()), | |
| 562 | - | } | |
| 563 | - | } else { | |
| 564 | - | None | |
| 553 | + | // completed_at tracks the status transition, not every edit: stamp it when | |
| 554 | + | // a task first becomes Completed, preserve it while it stays Completed, | |
| 555 | + | // clear it only when it actually leaves Completed, and otherwise leave the | |
| 556 | + | // stored value untouched. Lightweight context query (no sub-queries). | |
| 557 | + | let ctx = get_task_update_context(&self.pool, id, user_id).await?; | |
| 558 | + | let was_completed = ctx.as_ref().is_some_and(|c| c.status == TaskStatus::Completed); | |
| 559 | + | let prior_completed_at = ctx.as_ref().and_then(|c| c.completed_at.as_ref().map(format_datetime)); | |
| 560 | + | let completed_at_str: Option<String> = match task.status { | |
| 561 | + | TaskStatus::Completed if was_completed => prior_completed_at, // stays completed | |
| 562 | + | TaskStatus::Completed => Some(format_datetime_now()), // transition in | |
| 563 | + | _ if was_completed => None, // transition out: clear | |
| 564 | + | _ => prior_completed_at, // stays non-completed | |
| 565 | 565 | }; | |
| 566 | 566 | ||
| 567 | 567 | let result = sqlx::query( | |
| @@ -934,7 +934,7 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 934 | 934 | } | |
| 935 | 935 | ||
| 936 | 936 | #[tracing::instrument(skip_all)] | |
| 937 | - | async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: i32, date: DateTime<Utc>) -> Result<TimeSession> { | |
| 937 | + | async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: PositiveMinutes, date: DateTime<Utc>) -> Result<TimeSession> { | |
| 938 | 938 | time_session_repo::log_manual_time(&self.pool, task_id, user_id, minutes, date).await | |
| 939 | 939 | } | |
| 940 | 940 |
| @@ -8,7 +8,7 @@ use chrono::{DateTime, Utc}; | |||
| 8 | 8 | use sqlx::SqlitePool; | |
| 9 | 9 | ||
| 10 | 10 | use goingson_core::{ | |
| 11 | - | CoreError, Result, TaskId, TimeSession, TimeSessionId, TimeTrackingSummary, UserId, | |
| 11 | + | CoreError, PositiveMinutes, Result, TaskId, TimeSession, TimeSessionId, TimeTrackingSummary, UserId, | |
| 12 | 12 | }; | |
| 13 | 13 | ||
| 14 | 14 | use crate::utils::{format_datetime, format_datetime_now, parse_datetime, parse_uuid}; | |
| @@ -348,36 +348,40 @@ pub(crate) async fn log_manual_time( | |||
| 348 | 348 | pool: &SqlitePool, | |
| 349 | 349 | task_id: TaskId, | |
| 350 | 350 | user_id: UserId, | |
| 351 | - | minutes: i32, | |
| 351 | + | minutes: PositiveMinutes, | |
| 352 | 352 | date: DateTime<Utc>, | |
| 353 | 353 | ) -> Result<TimeSession> { | |
| 354 | 354 | use chrono::Duration; | |
| 355 | 355 | ||
| 356 | - | // Verify the task exists and belongs to this user *before* any write. The | |
| 357 | - | // session INSERT below binds `task_id` unconditionally and the task UPDATE | |
| 358 | - | // is user-scoped, so without this precheck a foreign/bogus id would leave an | |
| 359 | - | // orphan time_sessions row while silently no-op'ing the cache update. | |
| 356 | + | let minutes = minutes.as_i32(); | |
| 357 | + | ||
| 358 | + | let id = TimeSessionId::new(); | |
| 359 | + | let started_at = date; | |
| 360 | + | let ended_at = date + Duration::minutes(minutes as i64); | |
| 361 | + | let now = Utc::now(); | |
| 362 | + | ||
| 363 | + | let started_str = format_datetime(&started_at); | |
| 364 | + | let ended_str = format_datetime(&ended_at); | |
| 365 | + | let created_str = format_datetime(&now); | |
| 366 | + | ||
| 367 | + | // Single transaction so the session row and the task's cached actual_minutes | |
| 368 | + | // either both land or neither does. Ownership of `task_id` is checked first, | |
| 369 | + | // inside the same tx, so a foreign/bogus id can never leave an orphan session | |
| 370 | + | // row nor a silently no-op'd cache update. | |
| 371 | + | let mut tx = pool.begin().await.map_err(CoreError::database)?; | |
| 372 | + | ||
| 360 | 373 | let owns_task: Option<(i64,)> = sqlx::query_as( | |
| 361 | 374 | "SELECT 1 FROM tasks WHERE id = ? AND user_id = ?" | |
| 362 | 375 | ) | |
| 363 | 376 | .bind(task_id.to_string()) | |
| 364 | 377 | .bind(user_id.to_string()) | |
| 365 | - | .fetch_optional(pool) | |
| 378 | + | .fetch_optional(&mut *tx) | |
| 366 | 379 | .await | |
| 367 | 380 | .map_err(CoreError::database)?; | |
| 368 | 381 | if owns_task.is_none() { | |
| 369 | 382 | return Err(CoreError::not_found("task", task_id)); | |
| 370 | 383 | } | |
| 371 | 384 | ||
| 372 | - | let id = TimeSessionId::new(); | |
| 373 | - | let started_at = date; | |
| 374 | - | let ended_at = date + Duration::minutes(minutes as i64); | |
| 375 | - | let now = Utc::now(); | |
| 376 | - | ||
| 377 | - | let started_str = format_datetime(&started_at); | |
| 378 | - | let ended_str = format_datetime(&ended_at); | |
| 379 | - | let created_str = format_datetime(&now); | |
| 380 | - | ||
| 381 | 385 | sqlx::query( | |
| 382 | 386 | "INSERT INTO time_sessions (id, task_id, user_id, started_at, ended_at, duration_minutes, created_at) | |
| 383 | 387 | VALUES (?, ?, ?, ?, ?, ?, ?)" | |
| @@ -389,7 +393,7 @@ pub(crate) async fn log_manual_time( | |||
| 389 | 393 | .bind(&ended_str) | |
| 390 | 394 | .bind(minutes) | |
| 391 | 395 | .bind(&created_str) | |
| 392 | - | .execute(pool) | |
| 396 | + | .execute(&mut *tx) | |
| 393 | 397 | .await | |
| 394 | 398 | .map_err(CoreError::database)?; | |
| 395 | 399 | ||
| @@ -403,10 +407,12 @@ pub(crate) async fn log_manual_time( | |||
| 403 | 407 | .bind(minutes) | |
| 404 | 408 | .bind(task_id.to_string()) | |
| 405 | 409 | .bind(user_id.to_string()) | |
| 406 | - | .execute(pool) | |
| 410 | + | .execute(&mut *tx) | |
| 407 | 411 | .await | |
| 408 | 412 | .map_err(CoreError::database)?; | |
| 409 | 413 | ||
| 414 | + | tx.commit().await.map_err(CoreError::database)?; | |
| 415 | + | ||
| 410 | 416 | Ok(TimeSession { | |
| 411 | 417 | id, | |
| 412 | 418 | task_id, |
| @@ -11,7 +11,7 @@ mod common; | |||
| 11 | 11 | use goingson_core::backup_restore::RestoreInput; | |
| 12 | 12 | use goingson_core::{ | |
| 13 | 13 | AttachmentRepository, DailyNoteRepository, EventRepository, MilestoneRepository, NewAttachment, | |
| 14 | - | NewEvent, NewMilestone, NewProject, NewTask, ProjectRepository, SyncAccountRepository, | |
| 14 | + | NewEvent, NewMilestone, NewProject, NewTask, PositiveMinutes, ProjectRepository, SyncAccountRepository, | |
| 15 | 15 | TaskRepository, TaskStatus, | |
| 16 | 16 | }; | |
| 17 | 17 | use goingson_db_sqlite::{ | |
| @@ -340,7 +340,7 @@ async fn restore_round_trips_supplemental_collections() { | |||
| 340 | 340 | .await | |
| 341 | 341 | .unwrap(); | |
| 342 | 342 | let session = tasks | |
| 343 | - | .log_manual_time(task.id, user, 45, chrono::Utc::now()) | |
| 343 | + | .log_manual_time(task.id, user, PositiveMinutes::try_new(45).unwrap(), chrono::Utc::now()) | |
| 344 | 344 | .await | |
| 345 | 345 | .unwrap(); | |
| 346 | 346 | let note = daily_notes |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | mod common; | |
| 4 | 4 | ||
| 5 | - | use goingson_core::{CoreError, TaskId, TaskRepository}; | |
| 5 | + | use goingson_core::{CoreError, PositiveMinutes, TaskId, TaskRepository}; | |
| 6 | 6 | use goingson_db_sqlite::SqliteTaskRepository; | |
| 7 | 7 | ||
| 8 | 8 | #[tokio::test] | |
| @@ -261,7 +261,7 @@ async fn test_log_manual_time_happy_path() { | |||
| 261 | 261 | let repo = SqliteTaskRepository::new(pool); | |
| 262 | 262 | ||
| 263 | 263 | let session = repo | |
| 264 | - | .log_manual_time(task_id, user_id, 30, chrono::Utc::now()) | |
| 264 | + | .log_manual_time(task_id, user_id, PositiveMinutes::try_new(30).unwrap(), chrono::Utc::now()) | |
| 265 | 265 | .await | |
| 266 | 266 | .expect("logging manual time on an owned task should succeed"); | |
| 267 | 267 | assert_eq!(session.duration_minutes, Some(30)); | |
| @@ -284,7 +284,7 @@ async fn test_log_manual_time_foreign_task_rejected_no_orphan() { | |||
| 284 | 284 | ||
| 285 | 285 | let bogus_task = TaskId::new(); | |
| 286 | 286 | let result = repo | |
| 287 | - | .log_manual_time(bogus_task, user_id, 30, chrono::Utc::now()) | |
| 287 | + | .log_manual_time(bogus_task, user_id, PositiveMinutes::try_new(30).unwrap(), chrono::Utc::now()) | |
| 288 | 288 | .await; | |
| 289 | 289 | ||
| 290 | 290 | assert!(matches!(result, Err(CoreError::NotFound { .. })), "expected NOT_FOUND, got {result:?}"); |
| @@ -0,0 +1,136 @@ | |||
| 1 | + | //! Local blob-store garbage collection. | |
| 2 | + | //! | |
| 3 | + | //! The content-addressed blob store under `<data_dir>/blobs` accumulates files | |
| 4 | + | //! that lose every reference when attachments go away — either through | |
| 5 | + | //! `delete_attachment`, or through the `ON DELETE CASCADE` on | |
| 6 | + | //! `attachments.task_id` / `attachments.project_id`, which removes attachment | |
| 7 | + | //! rows without ever calling `delete_attachment`. Left alone the orphaned blobs | |
| 8 | + | //! are a permanent disk leak. | |
| 9 | + | //! | |
| 10 | + | //! [`reconcile`] walks the blob directory once at startup and unlinks any file | |
| 11 | + | //! that nothing references. References come from two places, and BOTH must be | |
| 12 | + | //! consulted — deleting a blob an email still points at would be data loss: | |
| 13 | + | //! 1. `attachments.blob_hash` — task / project attachments. | |
| 14 | + | //! 2. `emails.attachment_meta` — email attachments not yet converted to rows. | |
| 15 | + | //! | |
| 16 | + | //! Running at startup (before the sync/email schedulers spawn and before the UI | |
| 17 | + | //! can create attachments) keeps it free of races with concurrent blob writers. | |
| 18 | + | ||
| 19 | + | use std::collections::HashSet; | |
| 20 | + | use std::path::Path; | |
| 21 | + | ||
| 22 | + | use goingson_core::AttachmentMeta; | |
| 23 | + | use sqlx::SqlitePool; | |
| 24 | + | use tracing::{info, warn}; | |
| 25 | + | ||
| 26 | + | /// Collect every blob hash currently referenced by an attachment row or an | |
| 27 | + | /// email's `attachment_meta` JSON. | |
| 28 | + | pub async fn collect_referenced_hashes(pool: &SqlitePool) -> Result<HashSet<String>, sqlx::Error> { | |
| 29 | + | let mut referenced = HashSet::new(); | |
| 30 | + | ||
| 31 | + | let attachment_hashes: Vec<(String,)> = | |
| 32 | + | sqlx::query_as("SELECT DISTINCT blob_hash FROM attachments") | |
| 33 | + | .fetch_all(pool) | |
| 34 | + | .await?; | |
| 35 | + | referenced.extend(attachment_hashes.into_iter().map(|(h,)| h)); | |
| 36 | + | ||
| 37 | + | let metas: Vec<(Option<String>,)> = sqlx::query_as( | |
| 38 | + | "SELECT attachment_meta FROM emails WHERE attachment_meta IS NOT NULL AND attachment_meta != ''", | |
| 39 | + | ) | |
| 40 | + | .fetch_all(pool) | |
| 41 | + | .await?; | |
| 42 | + | for (meta,) in metas { | |
| 43 | + | let Some(json) = meta else { continue }; | |
| 44 | + | match serde_json::from_str::<Vec<AttachmentMeta>>(&json) { | |
| 45 | + | Ok(list) => referenced.extend(list.into_iter().map(|m| m.blob_hash)), | |
| 46 | + | // A malformed row must never widen the delete set: keep its blobs. | |
| 47 | + | Err(e) => warn!("Blob GC: skipping unparseable attachment_meta: {e}"), | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | Ok(referenced) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Remove blob files in `blobs_dir` whose name is not in `referenced`. | |
| 55 | + | /// | |
| 56 | + | /// Returns the number of files unlinked. Non-files and unreadable entries are | |
| 57 | + | /// skipped; a failed unlink is logged and does not abort the sweep. | |
| 58 | + | pub fn reclaim_orphans(blobs_dir: &Path, referenced: &HashSet<String>) -> std::io::Result<usize> { | |
| 59 | + | if !blobs_dir.exists() { | |
| 60 | + | return Ok(0); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | let mut removed = 0; | |
| 64 | + | for entry in std::fs::read_dir(blobs_dir)? { | |
| 65 | + | let entry = entry?; | |
| 66 | + | if !entry.file_type()?.is_file() { | |
| 67 | + | continue; | |
| 68 | + | } | |
| 69 | + | let name = entry.file_name(); | |
| 70 | + | let Some(name) = name.to_str() else { continue }; | |
| 71 | + | if referenced.contains(name) { | |
| 72 | + | continue; | |
| 73 | + | } | |
| 74 | + | match std::fs::remove_file(entry.path()) { | |
| 75 | + | Ok(()) => removed += 1, | |
| 76 | + | Err(e) => warn!("Blob GC: failed to remove orphan {name}: {e}"), | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | Ok(removed) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Reconcile the blob store against live references and unlink orphans. | |
| 83 | + | /// | |
| 84 | + | /// Best-effort: any failure is logged and swallowed so startup is never blocked. | |
| 85 | + | pub async fn reconcile(pool: &SqlitePool, data_dir: &Path) { | |
| 86 | + | let referenced = match collect_referenced_hashes(pool).await { | |
| 87 | + | Ok(set) => set, | |
| 88 | + | Err(e) => { | |
| 89 | + | warn!("Blob GC skipped: could not collect references: {e}"); | |
| 90 | + | return; | |
| 91 | + | } | |
| 92 | + | }; | |
| 93 | + | ||
| 94 | + | 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}"), | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | #[cfg(test)] | |
| 103 | + | mod tests { | |
| 104 | + | use super::*; | |
| 105 | + | ||
| 106 | + | #[test] | |
| 107 | + | fn reclaim_removes_only_unreferenced_files() { | |
| 108 | + | let dir = std::env::temp_dir().join(format!("goingson-blobgc-test-{}", std::process::id())); | |
| 109 | + | let blobs = dir.join("blobs"); | |
| 110 | + | std::fs::create_dir_all(&blobs).unwrap(); | |
| 111 | + | ||
| 112 | + | std::fs::write(blobs.join("keep_me"), b"a").unwrap(); | |
| 113 | + | std::fs::write(blobs.join("orphan_1"), b"b").unwrap(); | |
| 114 | + | std::fs::write(blobs.join("orphan_2"), b"c").unwrap(); | |
| 115 | + | ||
| 116 | + | let mut referenced = HashSet::new(); | |
| 117 | + | referenced.insert("keep_me".to_string()); | |
| 118 | + | // A referenced-but-missing hash must not error (blob simply not local yet). | |
| 119 | + | referenced.insert("not_on_disk".to_string()); | |
| 120 | + | ||
| 121 | + | let removed = reclaim_orphans(&blobs, &referenced).unwrap(); | |
| 122 | + | assert_eq!(removed, 2); | |
| 123 | + | assert!(blobs.join("keep_me").exists()); | |
| 124 | + | assert!(!blobs.join("orphan_1").exists()); | |
| 125 | + | assert!(!blobs.join("orphan_2").exists()); | |
| 126 | + | ||
| 127 | + | std::fs::remove_dir_all(&dir).ok(); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn reclaim_on_missing_dir_is_noop() { | |
| 132 | + | let missing = std::env::temp_dir().join("goingson-blobgc-does-not-exist-xyz"); | |
| 133 | + | let removed = reclaim_orphans(&missing, &HashSet::new()).unwrap(); | |
| 134 | + | assert_eq!(removed, 0); | |
| 135 | + | } | |
| 136 | + | } |
| @@ -77,6 +77,17 @@ pub async fn add_attachment( | |||
| 77 | 77 | return Err(ApiError::validation_msg("Either taskId or projectId is required")); | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | + | // Verify the parent exists *before* touching the blob store, so a bad id | |
| 81 | + | // fails fast instead of after a disk write orphans a blob. | |
| 82 | + | if let Some(tid) = task_id { | |
| 83 | + | state.tasks.get_by_id(tid, DESKTOP_USER_ID).await? | |
| 84 | + | .or_not_found("task", tid)?; | |
| 85 | + | } | |
| 86 | + | if let Some(pid) = project_id { | |
| 87 | + | state.projects.get_by_id(pid, DESKTOP_USER_ID).await? | |
| 88 | + | .or_not_found("project", pid)?; | |
| 89 | + | } | |
| 90 | + | ||
| 80 | 91 | let source_path = Path::new(&file_path); | |
| 81 | 92 | ||
| 82 | 93 | // Validate path exists and is a file | |
| @@ -116,9 +127,11 @@ pub async fn add_attachment( | |||
| 116 | 127 | std::fs::create_dir_all(&blobs_dir) | |
| 117 | 128 | .map_api_err("Failed to create blobs directory", ApiError::internal)?; | |
| 118 | 129 | ||
| 119 | - | // Copy to blob store (skip if hash already exists — dedup) | |
| 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. | |
| 120 | 132 | let blob_path = blobs_dir.join(&hash); | |
| 121 | - | if !blob_path.exists() { | |
| 133 | + | let wrote_new_blob = !blob_path.exists(); | |
| 134 | + | if wrote_new_blob { | |
| 122 | 135 | std::fs::write(&blob_path, &file_data) | |
| 123 | 136 | .map_api_err("Failed to write blob", ApiError::internal)?; | |
| 124 | 137 | } | |
| @@ -133,7 +146,7 @@ pub async fn add_attachment( | |||
| 133 | 146 | let mime_type = mime_from_extension(&filename).to_string(); | |
| 134 | 147 | let file_size = file_data.len() as i64; | |
| 135 | 148 | ||
| 136 | - | let attachment = state.attachments | |
| 149 | + | let create_result = state.attachments | |
| 137 | 150 | .create(DESKTOP_USER_ID, NewAttachment { | |
| 138 | 151 | task_id, | |
| 139 | 152 | project_id, | |
| @@ -143,7 +156,19 @@ pub async fn add_attachment( | |||
| 143 | 156 | blob_hash: hash, | |
| 144 | 157 | source_email_id: None, | |
| 145 | 158 | }) | |
| 146 | - | .await?; | |
| 159 | + | .await; | |
| 160 | + | ||
| 161 | + | let attachment = match create_result { | |
| 162 | + | Ok(a) => a, | |
| 163 | + | Err(e) => { | |
| 164 | + | // The row never landed; reclaim the blob we just wrote so it doesn't | |
| 165 | + | // leak. Only remove a blob we created this call (dedup hits must stay). | |
| 166 | + | if wrote_new_blob { | |
| 167 | + | let _ = std::fs::remove_file(&blob_path); | |
| 168 | + | } | |
| 169 | + | return Err(e.into()); | |
| 170 | + | } | |
| 171 | + | }; | |
| 147 | 172 | ||
| 148 | 173 | Ok(to_response(attachment, &state.data_dir)) | |
| 149 | 174 | } |
| @@ -112,16 +112,13 @@ pub async fn log_manual_time( | |||
| 112 | 112 | state: State<'_, Arc<AppState>>, | |
| 113 | 113 | input: LogManualTimeInput, | |
| 114 | 114 | ) -> Result<TimeSession, ApiError> { | |
| 115 | - | // Reject non-positive durations: a negative value produces ended_at < | |
| 116 | - | // started_at and decrements the task's actual_minutes cache (can go | |
| 117 | - | // negative); zero records an empty session. | |
| 118 | - | if input.minutes < 1 { | |
| 119 | - | return Err(ApiError::validation("minutes", "Minutes must be at least 1")); | |
| 120 | - | } | |
| 121 | - | // Ownership of `task_id` is verified inside the repo method (it must precede | |
| 122 | - | // the session INSERT to avoid an orphan row); a foreign/bogus id surfaces as | |
| 123 | - | // a NOT_FOUND error here. | |
| 124 | - | Ok(state.tasks.log_manual_time(input.task_id, DESKTOP_USER_ID, input.minutes, input.date).await?) | |
| 115 | + | // Validate the duration via the newtype: anything below 1 is rejected here | |
| 116 | + | // (a negative value would make ended_at < started_at and drive the task's | |
| 117 | + | // actual_minutes cache negative; zero records an empty session). | |
| 118 | + | let minutes = goingson_core::PositiveMinutes::try_new(input.minutes)?; | |
| 119 | + | // Ownership of `task_id` is verified inside the repo method, within the same | |
| 120 | + | // transaction as the writes; a foreign/bogus id surfaces as NOT_FOUND. | |
| 121 | + | Ok(state.tasks.log_manual_time(input.task_id, DESKTOP_USER_ID, minutes, input.date).await?) | |
| 125 | 122 | } | |
| 126 | 123 | ||
| 127 | 124 | /// Gets time tracking summary grouped by project and date. |
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | //! or via `mobile_entry_point` (iOS/Android). | |
| 6 | 6 | ||
| 7 | 7 | pub mod backup_scheduler; | |
| 8 | + | pub mod blob_gc; | |
| 8 | 9 | pub mod commands; | |
| 9 | 10 | pub mod email; | |
| 10 | 11 | pub mod email_sync_scheduler; | |
| @@ -314,6 +315,9 @@ pub fn build_mobile_app() -> tauri::Builder<tauri::Wry> { | |||
| 314 | 315 | tauri::async_runtime::block_on(async move { | |
| 315 | 316 | let state = AppState::new(&app_handle).await | |
| 316 | 317 | .expect("Failed to initialize app state"); | |
| 318 | + | // Reclaim orphaned attachment blobs before the schedulers spawn | |
| 319 | + | // and before the UI can create attachments — race-free here. | |
| 320 | + | blob_gc::reconcile(&state.pool, &state.data_dir).await; | |
| 317 | 321 | app_handle.manage(Arc::new(state)); | |
| 318 | 322 | }); | |
| 319 | 323 |