Skip to main content

max / goingson

fix(storage): blob GC, attachment write-order, atomic positive-only manual time Axis 4 of the Run #26 ultra-fuzz remediation. UF-7: add blob_gc, a startup reconcile that unlinks orphaned attachment blobs. The reference set is the union of attachments.blob_hash AND every blob hash in emails.attachment_meta, so a blob an email still points at is never deleted. Runs before the schedulers spawn and before the UI can create attachments, so it is race-free, and it reclaims both ON DELETE CASCADE leaks and ordinary deletes (which previously left blobs on disk forever). UF-8: add_attachment verifies the parent task/project exists before writing any blob, and removes a freshly-written blob if the row INSERT fails (dedup hits are preserved). CHRONIC-B: log_manual_time takes a validated PositiveMinutes(NonZeroU32) instead of a raw i32, and runs the ownership check, session INSERT, and task-cache UPDATE in a single transaction. The redundant command-layer guard is removed. Low: search_repo documents its total as a deliberate lower bound; task_repo only changes completed_at on an actual status transition; the email-id migration detaches its FK-disabled connection so an early return can't hand it back to the pool. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-16 02:55 UTC
Commit: 735059d650e3794992e4717608bddb9b5ed8dd19
Parent: 7025358
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