Skip to main content

max / goingson

Fix restore data-loss (C1) and make backup completeness structural Storage axis remediation (ultra-fuzz Run #27): - C1 CRITICAL: restore_all now sets PRAGMA defer_foreign_keys, so a recurring child task whose recurrence_parent_id sorts before its parent no longer raises FK 787 and rolls back the entire restore. Disaster recovery was broken for any user with a completed recurring task. - CHRONIC-D: backup/restore now carry saved_views, weekly_reviews (+ vacation days), monthly_goals, and monthly_reflections, which were silently dropped. A BACKUP_TABLES registry plus a schema-coverage test make a new user table that is neither backed up nor explicitly excluded a test failure, so the backup-omission class can no longer recur silently. - collect_full_export is now parameterized by user_id (testable, no hardcoded desktop constant in the single-source gather). - Minors: partial UNIQUE index on the active timer (migration 053) closes the double-start TOCTOU; search_repo clamps negative limit/offset. Tests: schema-coverage gate, C1 recurring-task restore guard, and a full gather -> serialize -> restore round-trip for the new collections.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 23:26 UTC
Signed with PGP, not checked
Commit: d660e8e81c259829e7a8922ce9ef5c3e30e59547
Parent: 67894af
15 files changed, +650 insertions, -37 deletions
@@ -39,17 +39,22 @@
39 39 /// time_sessions, milestones, daily_notes, attachments, and sync_accounts).
40 40 pub(crate) async fn collect_full_export(
41 41 state: &AppState,
42 + user_id: goingson_core::UserId,
42 43 ) -> Result<FullExport, goingson_core::CoreError> {
43 - let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
44 - let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
45 - let events = state.events.list_all(DESKTOP_USER_ID).await?;
46 - let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?;
47 - let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?;
48 - let time_sessions = state.tasks.list_all_time_sessions(DESKTOP_USER_ID).await?;
49 - let milestones = state.milestones.list_all(DESKTOP_USER_ID).await?;
50 - let daily_notes = state.daily_notes.list_all(DESKTOP_USER_ID).await?;
51 - let attachments = state.attachments.list_all(DESKTOP_USER_ID).await?;
52 - let sync_accounts = state.sync_accounts.list_all(DESKTOP_USER_ID).await?;
44 + let projects = state.projects.list_all(user_id).await?;
45 + let tasks = state.tasks.list_all(user_id).await?;
46 + let events = state.events.list_all(user_id).await?;
47 + let emails = state.emails.list_all(user_id, true).await?;
48 + let contacts = state.contacts.list_all(user_id).await?;
49 + let time_sessions = state.tasks.list_all_time_sessions(user_id).await?;
50 + let milestones = state.milestones.list_all(user_id).await?;
51 + let daily_notes = state.daily_notes.list_all(user_id).await?;
52 + let attachments = state.attachments.list_all(user_id).await?;
53 + let sync_accounts = state.sync_accounts.list_all(user_id).await?;
54 + let saved_views = state.saved_views.list_all(user_id).await?;
55 + let weekly_reviews = state.weekly_reviews.list_all(user_id).await?;
56 + let monthly_goals = state.monthly_reviews.list_all_goals(user_id).await?;
57 + let monthly_reflections = state.monthly_reviews.list_all_reflections(user_id).await?;
53 58 Ok(FullExport::new(
54 59 projects,
55 60 tasks,
@@ -61,6 +66,10 @@
61 66 daily_notes,
62 67 attachments,
63 68 sync_accounts,
69 + saved_views,
70 + weekly_reviews,
71 + monthly_goals,
72 + monthly_reflections,
64 73 ))
65 74 }
66 75
@@ -155,7 +164,7 @@
155 164 let filename = backup_filename(now);
156 165 let file_path = backup_dir.join(&filename);
157 166
158 - let export = collect_full_export(state).await.map_err(|e| e.to_string())?;
167 + let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?;
159 168 let item_count = export.total_count();
160 169 let max_to_keep = settings.max_backups_to_keep as usize;
161 170
@@ -253,7 +262,7 @@
253 262 let filename = backup_filename(now);
254 263 let file_path = backup_dir.join(&filename);
255 264
256 - let export = collect_full_export(state).await.map_err(|e| e.to_string())?;
265 + let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?;
257 266 let item_count = export.total_count();
258 267 let size_bytes = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?;
259 268
@@ -6,7 +6,10 @@
6 6 //! and sub-collections are written verbatim; this module only defines the types that
7 7 //! cross the command -> storage boundary.
8 8
9 - use crate::models::{Attachment, DailyNote, Email, Event, Milestone, Project, SyncAccount, Task, TimeSession};
9 + use crate::models::{
10 + Attachment, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection, Project,
11 + SavedView, SyncAccount, Task, TimeSession, WeeklyReview,
12 + };
10 13 use crate::Contact;
11 14
12 15 /// Result of a restore operation.
@@ -36,6 +39,14 @@
36 39 pub attachments_restored: usize,
37 40 /// Number of sync accounts restored.
38 41 pub sync_accounts_restored: usize,
42 + /// Number of saved views restored.
43 + pub saved_views_restored: usize,
44 + /// Number of weekly reviews restored.
45 + pub weekly_reviews_restored: usize,
46 + /// Number of monthly goals restored.
47 + pub monthly_goals_restored: usize,
48 + /// Number of monthly reflections restored.
49 + pub monthly_reflections_restored: usize,
39 50 }
40 51
41 52 /// Pre-parsed backup data for restoration.
@@ -50,6 +61,10 @@
50 61 pub daily_notes: Vec<DailyNote>,
51 62 pub attachments: Vec<Attachment>,
52 63 pub sync_accounts: Vec<SyncAccount>,
64 + pub saved_views: Vec<SavedView>,
65 + pub weekly_reviews: Vec<WeeklyReview>,
66 + pub monthly_goals: Vec<MonthlyGoal>,
67 + pub monthly_reflections: Vec<MonthlyReflection>,
53 68 }
54 69
55 70 #[cfg(test)]
@@ -78,6 +93,10 @@
78 93 daily_notes: vec![],
79 94 attachments: vec![],
80 95 sync_accounts: vec![],
96 + saved_views: vec![],
97 + weekly_reviews: vec![],
98 + monthly_goals: vec![],
99 + monthly_reflections: vec![],
81 100 };
82 101 assert!(input.projects.is_empty());
83 102 assert!(input.tasks.is_empty());
@@ -809,6 +809,9 @@
809 809 /// Creates or updates a weekly review.
810 810 async fn upsert(&self, user_id: UserId, week_start: NaiveDate, notes: &str) -> Result<crate::models::WeeklyReview>;
811 811
812 + /// Lists every weekly review for a user across all weeks (for full backup export).
813 + async fn list_all(&self, user_id: UserId) -> Result<Vec<crate::models::WeeklyReview>>;
814 +
812 815 /// Checks if the current week's review is completed.
813 816 async fn is_current_week_completed(&self, user_id: UserId) -> Result<bool>;
814 817
@@ -835,6 +838,12 @@
835 838 /// Gets all goals for a specific month.
836 839 async fn list_goals(&self, user_id: UserId, month: &str) -> Result<Vec<crate::models::MonthlyGoal>>;
837 840
841 + /// Lists every goal for a user across all months (for full backup export).
842 + async fn list_all_goals(&self, user_id: UserId) -> Result<Vec<crate::models::MonthlyGoal>>;
843 +
844 + /// Lists every reflection for a user across all months (for full backup export).
845 + async fn list_all_reflections(&self, user_id: UserId) -> Result<Vec<crate::models::MonthlyReflection>>;
846 +
838 847 /// Creates or updates a goal for a month at a given position (1-3).
839 848 async fn upsert_goal(&self, user_id: UserId, month: &str, text: &str, position: i32) -> Result<crate::models::MonthlyGoal>;
840 849
@@ -72,4 +72,4 @@
72 72 SqliteWeeklyReviewRepository,
73 73 };
74 74
75 - pub use repository::restore::restore_all;
75 + pub use repository::restore::{restore_all, BACKUP_TABLES, EXCLUDED_TABLES};
@@ -75,6 +75,10 @@
75 75 daily_notes: vec![],
76 76 attachments: vec![],
77 77 sync_accounts: vec![],
78 + saved_views: vec![],
79 + weekly_reviews: vec![],
80 + monthly_goals: vec![],
81 + monthly_reflections: vec![],
78 82 };
79 83
80 84 // First restore recreates with status + completed_at + id preserved.
@@ -181,6 +185,10 @@
181 185 daily_notes: vec![],
182 186 attachments: vec![],
183 187 sync_accounts: vec![],
188 + saved_views: vec![],
189 + weekly_reviews: vec![],
190 + monthly_goals: vec![],
191 + monthly_reflections: vec![],
184 192 };
185 193 let r = restore_all(&pool, user_id, &input).await.unwrap();
186 194 assert_eq!(r.tasks_restored, 1);
@@ -274,6 +282,10 @@
274 282 daily_notes: vec![],
275 283 attachments: vec![],
276 284 sync_accounts: vec![],
285 + saved_views: vec![],
286 + weekly_reviews: vec![],
287 + monthly_goals: vec![],
288 + monthly_reflections: vec![],
277 289 };
278 290 let r = restore_all(&pool, user_id, &input).await.unwrap();
279 291 assert_eq!(r.events_restored, 1);
@@ -385,6 +397,10 @@
385 397 daily_notes: vec![note.clone()],
386 398 attachments: vec![attachment.clone()],
387 399 sync_accounts: vec![account.clone()],
400 + saved_views: vec![],
401 + weekly_reviews: vec![],
402 + monthly_goals: vec![],
403 + monthly_reflections: vec![],
388 404 };
389 405 // Deleting tasks cascades time_sessions + attachments; deleting projects
390 406 // cascades milestones; daily_notes and sync_accounts are independent.
@@ -139,7 +139,7 @@
139 139 ) -> Result<ExportResponse, ApiError> {
140 140 validate_export_path(&file_path)?;
141 141
142 - let export = crate::backup_scheduler::collect_full_export(&state).await?;
142 + let export = crate::backup_scheduler::collect_full_export(&state, DESKTOP_USER_ID).await?;
143 143 let item_count = export.total_count();
144 144
145 145 let size_bytes = backup::write_json(&export, &file_path)
@@ -260,7 +260,7 @@
260 260 let filename = format!("goingson-backup-{}.json.gz", timestamp);
261 261 let file_path = backup_dir.join(&filename);
262 262
263 - let export = crate::backup_scheduler::collect_full_export(&state).await?;
263 + let export = crate::backup_scheduler::collect_full_export(&state, DESKTOP_USER_ID).await?;
264 264 let item_count = export.total_count();
265 265
266 266 let size_bytes = backup::write_backup(&export, &file_path)
@@ -380,6 +380,10 @@
380 380 daily_notes: export.daily_notes,
381 381 attachments: export.attachments,
382 382 sync_accounts: export.sync_accounts,
383 + saved_views: export.saved_views,
384 + weekly_reviews: export.weekly_reviews,
385 + monthly_goals: export.monthly_goals,
386 + monthly_reflections: export.monthly_reflections,
383 387 };
384 388
385 389 // Single all-or-nothing transaction over the shared pool: a mid-restore failure