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
Commit: d660e8e81c259829e7a8922ce9ef5c3e30e59547
Parent: 67894af
15 files changed, +650 insertions, -37 deletions
@@ -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 @@ pub struct RestoreResult {
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 @@ pub struct RestoreInput {
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 @@ mod tests {
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());
@@ -113,8 +113,10 @@ pub struct ViewFilters {
113 113 pub struct SavedView {
114 114 /// Unique view ID.
115 115 pub id: SavedViewId,
116 - /// User who owns this view.
117 - #[serde(skip_serializing)]
116 + /// User who owns this view. Not serialized into API responses; `default` lets a
117 + /// backup (which omits it) still deserialize -- restore re-homes the row to the
118 + /// target user, so the placeholder is never persisted.
119 + #[serde(default, skip_serializing)]
118 120 pub user_id: UserId,
119 121 /// Display name for the view.
120 122 pub name: String,
@@ -809,6 +809,9 @@ pub trait WeeklyReviewRepository: Send + Sync {
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 @@ pub trait MonthlyReviewRepository: Send + Sync {
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 @@ pub use repository::{
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};
@@ -106,6 +106,42 @@ impl MonthlyReviewRepository for SqliteMonthlyReviewRepository {
106 106 }
107 107
108 108 #[tracing::instrument(skip_all)]
109 + async fn list_all_goals(&self, user_id: UserId) -> Result<Vec<MonthlyGoal>> {
110 + let user_id_str = user_id.to_string();
111 +
112 + let rows: Vec<MonthlyGoalRow> = sqlx::query_as(
113 + "SELECT id, user_id, month, text, status, position, created_at, updated_at
114 + FROM monthly_goals
115 + WHERE user_id = ?
116 + ORDER BY month, position",
117 + )
118 + .bind(&user_id_str)
119 + .fetch_all(&self.pool)
120 + .await
121 + .map_err(CoreError::database)?;
122 +
123 + rows.into_iter().map(MonthlyGoal::try_from).collect()
124 + }
125 +
126 + #[tracing::instrument(skip_all)]
127 + async fn list_all_reflections(&self, user_id: UserId) -> Result<Vec<MonthlyReflection>> {
128 + let user_id_str = user_id.to_string();
129 +
130 + let rows: Vec<MonthlyReflectionRow> = sqlx::query_as(
131 + "SELECT id, user_id, month, highlight_text, change_text, completed_at
132 + FROM monthly_reflections
133 + WHERE user_id = ?
134 + ORDER BY month",
135 + )
136 + .bind(&user_id_str)
137 + .fetch_all(&self.pool)
138 + .await
139 + .map_err(CoreError::database)?;
140 +
141 + rows.into_iter().map(MonthlyReflection::try_from).collect()
142 + }
143 +
144 + #[tracing::instrument(skip_all)]
109 145 async fn upsert_goal(&self, user_id: UserId, month: &str, text: &str, position: i32) -> Result<MonthlyGoal> {
110 146 let user_id_str = user_id.to_string();
111 147 let now = format_datetime(&Utc::now());
@@ -14,11 +14,56 @@
14 14 //! changelog triggers fire as normal, so restored rows propagate to other devices.
15 15
16 16 use goingson_core::backup_restore::{RestoreInput, RestoreResult};
17 + use goingson_core::models::SortDirection;
17 18 use goingson_core::{CoreError, DbValue, Result, UserId};
18 19 use sqlx::{Sqlite, SqlitePool, Transaction};
19 20
20 21 use crate::utils::{format_datetime, format_datetime_opt};
21 22
23 + /// Every table a full backup must capture and restore -- the single source of
24 + /// truth for backup completeness. `collect_full_export` gathers each of these and
25 + /// `restore_all` writes each back; the `backup_tables_match_schema` test asserts
26 + /// this list equals the live schema minus [`EXCLUDED_TABLES`], so adding a table to
27 + /// the schema without registering it here (or excluding it) is a test failure. This
28 + /// is the structural fix for the recurring "backup silently omitted table X" finding
29 + /// (ultra-fuzz CHRONIC-D), mirroring the trigger/synced-column round-trip test.
30 + pub const BACKUP_TABLES: &[&str] = &[
31 + "projects",
32 + "tasks",
33 + "annotations",
34 + "subtasks",
35 + "events",
36 + "emails",
37 + "contacts",
38 + "contact_emails",
39 + "contact_phones",
40 + "contact_social_handles",
41 + "contact_custom_fields",
42 + "milestones",
43 + "time_sessions",
44 + "daily_notes",
45 + "attachments",
46 + "sync_accounts",
47 + "saved_views",
48 + "weekly_reviews",
49 + "monthly_goals",
50 + "monthly_reflections",
51 + ];
52 +
53 + /// Tables deliberately excluded from backups, each for a documented reason. A table
54 + /// must appear in either [`BACKUP_TABLES`] or here; the coverage test fails otherwise.
55 + pub const EXCLUDED_TABLES: &[&str] = &[
56 + "users", // single fixed desktop user, recreated on init
57 + "email_accounts", // credentials/config; secrets live in the OS keychain, re-auth on restore
58 + "llm_settings", // local config / API keys, not user content
59 + "llm_cache", // regenerable cache
60 + "backup_settings", // local backup cadence/retention config
61 + "backup_settings_new", // transient table-rename scaffold (absent post-migration)
62 + "imap_folder_sync_state", // sync-transient IMAP state, re-derived on next sync
63 + "sync_changelog", // sync internals, regenerated
64 + "sync_state", // sync cursors/flags, internal
65 + ];
66 +
22 67 /// Restore a backup into the database as a single transaction.
23 68 ///
24 69 /// On any error the transaction is dropped without committing, so the database is
@@ -31,11 +76,24 @@ pub async fn restore_all(
31 76 let mut tx = pool.begin().await.map_err(CoreError::database)?;
32 77 let mut result = RestoreResult::default();
33 78
34 - // Ordered so every foreign key's target is inserted first; otherwise an
35 - // INSERT OR IGNORE silently drops a row that references a not-yet-restored
36 - // parent. projects/contacts are roots; milestones and emails are parents of
37 - // tasks; tasks parent time_sessions/events/attachments; daily_notes and
38 - // sync_accounts are independent.
79 + // Defer foreign-key enforcement to COMMIT. Without this, a self-referential FK
80 + // (tasks/events `recurrence_parent_id`) raises immediately when a child instance
81 + // is inserted before its parent root -- and because the backup is emitted in
82 + // urgency/created order, the child routinely precedes the parent. An immediate
83 + // FK violation (code 787) aborts the whole transaction, losing the entire
84 + // restore (ultra-fuzz Run #27 C1). Deferring checks them once, at commit, by
85 + // which point every referent in the backup has been inserted; only a genuinely
86 + // dangling reference still fails. `INSERT OR IGNORE` does NOT suppress FK
87 + // violations -- it only suppresses UNIQUE/NOT NULL/CHECK conflicts.
88 + sqlx::query("PRAGMA defer_foreign_keys = ON")
89 + .execute(&mut *tx)
90 + .await
91 + .map_err(CoreError::database)?;
92 +
93 + // The insert order below is no longer load-bearing for FK correctness (deferred
94 + // checks make any order valid as long as every referent is present), but is kept
95 + // roots-first for readability. Every table in `BACKUP_TABLES` must be restored
96 + // here; the round-trip test enforces it.
39 97 restore_projects(&mut tx, user_id, input, &mut result).await?;
40 98 restore_contacts_with_children(&mut tx, user_id, input, &mut result).await?;
41 99 restore_milestones(&mut tx, user_id, input, &mut result).await?;
@@ -46,6 +104,10 @@ pub async fn restore_all(
46 104 restore_attachments(&mut tx, user_id, input, &mut result).await?;
47 105 restore_daily_notes(&mut tx, user_id, input, &mut result).await?;
48 106 restore_sync_accounts(&mut tx, user_id, input, &mut result).await?;
107 + restore_saved_views(&mut tx, user_id, input, &mut result).await?;
108 + restore_weekly_reviews(&mut tx, user_id, input, &mut result).await?;
109 + restore_monthly_goals(&mut tx, user_id, input, &mut result).await?;
110 + restore_monthly_reflections(&mut tx, user_id, input, &mut result).await?;
49 111
50 112 tx.commit().await.map_err(CoreError::database)?;
51 113 Ok(result)
@@ -609,3 +671,144 @@ async fn restore_sync_accounts(
609 671 }
610 672 Ok(())
611 673 }
674 +
675 + async fn restore_saved_views(
676 + tx: &mut Transaction<'_, Sqlite>,
677 + user_id: UserId,
678 + input: &RestoreInput,
679 + result: &mut RestoreResult,
680 + ) -> Result<()> {
681 + for view in &input.saved_views {
682 + let filters_json = serde_json::to_string(&view.filters).unwrap_or_else(|_| "{}".to_string());
683 + let sort_by = view.sort_by.and_then(|sf| {
684 + serde_json::to_value(sf)
685 + .ok()
686 + .and_then(|v| v.as_str().map(String::from))
687 + });
688 + let sort_order = match view.sort_order {
689 + SortDirection::Desc => "desc",
690 + SortDirection::Asc => "asc",
691 + };
692 + let affected = sqlx::query(
693 + "INSERT OR IGNORE INTO saved_views (\
694 + id, user_id, name, view_type, filters, sort_by, sort_order, is_pinned, position, \
695 + created_at, updated_at\
696 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
697 + )
698 + .bind(view.id.to_string())
699 + .bind(user_id.to_string())
700 + .bind(&view.name)
701 + .bind(view.view_type.db_value())
702 + .bind(&filters_json)
703 + .bind(&sort_by)
704 + .bind(sort_order)
705 + .bind(if view.is_pinned { 1 } else { 0 })
706 + .bind(view.position)
707 + .bind(format_datetime(&view.created_at))
708 + .bind(format_datetime(&view.updated_at))
709 + .execute(&mut **tx)
710 + .await
711 + .map_err(CoreError::database)?
712 + .rows_affected();
713 + if affected > 0 {
714 + result.saved_views_restored += 1;
715 + }
716 + }
717 + Ok(())
718 + }
719 +
720 + async fn restore_weekly_reviews(
721 + tx: &mut Transaction<'_, Sqlite>,
722 + user_id: UserId,
723 + input: &RestoreInput,
724 + result: &mut RestoreResult,
725 + ) -> Result<()> {
726 + for review in &input.weekly_reviews {
727 + let vacation_days = review
728 + .vacation_days
729 + .iter()
730 + .filter(|&&d| d <= 6)
731 + .map(|d| d.to_string())
732 + .collect::<Vec<_>>()
733 + .join(",");
734 + let affected = sqlx::query(
735 + "INSERT OR IGNORE INTO weekly_reviews (\
736 + id, user_id, week_start_date, completed_at, notes, vacation_days\
737 + ) VALUES (?, ?, ?, ?, ?, ?)",
738 + )
739 + .bind(review.id.to_string())
740 + .bind(user_id.to_string())
741 + .bind(review.week_start_date.format("%Y-%m-%d").to_string())
742 + .bind(format_datetime(&review.completed_at))
743 + .bind(&review.notes)
744 + .bind(&vacation_days)
745 + .execute(&mut **tx)
746 + .await
747 + .map_err(CoreError::database)?
748 + .rows_affected();
749 + if affected > 0 {
750 + result.weekly_reviews_restored += 1;
751 + }
752 + }
753 + Ok(())
754 + }
755 +
756 + async fn restore_monthly_goals(
757 + tx: &mut Transaction<'_, Sqlite>,
758 + user_id: UserId,
759 + input: &RestoreInput,
760 + result: &mut RestoreResult,
761 + ) -> Result<()> {
762 + for goal in &input.monthly_goals {
763 + let affected = sqlx::query(
764 + "INSERT OR IGNORE INTO monthly_goals (\
765 + id, user_id, month, text, status, position, created_at, updated_at\
766 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
767 + )
768 + .bind(goal.id.to_string())
769 + .bind(user_id.to_string())
770 + .bind(&goal.month)
771 + .bind(&goal.text)
772 + .bind(goal.status.as_str())
773 + .bind(goal.position)
774 + .bind(format_datetime(&goal.created_at))
775 + .bind(format_datetime(&goal.updated_at))
776 + .execute(&mut **tx)
777 + .await
778 + .map_err(CoreError::database)?
779 + .rows_affected();
780 + if affected > 0 {
781 + result.monthly_goals_restored += 1;
782 + }
783 + }
784 + Ok(())
785 + }
786 +
787 + async fn restore_monthly_reflections(
788 + tx: &mut Transaction<'_, Sqlite>,
789 + user_id: UserId,
790 + input: &RestoreInput,
791 + result: &mut RestoreResult,
792 + ) -> Result<()> {
793 + for reflection in &input.monthly_reflections {
794 + let affected = sqlx::query(
795 + "INSERT OR IGNORE INTO monthly_reflections (\
796 + id, user_id, month, highlight_text, change_text, completed_at\
797 + ) VALUES (?, ?, ?, ?, ?, ?)",
798 + )
799 + .bind(reflection.id.to_string())
800 + .bind(user_id.to_string())
801 + .bind(&reflection.month)
802 + .bind(&reflection.highlight_text)
803 + .bind(&reflection.change_text)
804 + .bind(format_datetime(&reflection.completed_at))
805 + .execute(&mut **tx)
806 + .await
807 + .map_err(CoreError::database)?
808 + .rows_affected();
809 + if affected > 0 {
810 + result.monthly_reflections_restored += 1;
811 + }
812 + }
813 + Ok(())
814 + }
@@ -52,8 +52,10 @@ impl SearchRepository for SqliteSearchRepository {
52 52 }
53 53
54 54 let user_id_str = user_id.to_string();
55 - let limit = query.limit.unwrap_or(50);
56 - let offset = query.offset.unwrap_or(0);
55 + // Clamp to non-negative: a negative limit/offset would otherwise be
56 + // interpolated as `LIMIT -N`, which SQLite treats as unbounded.
57 + let limit = query.limit.unwrap_or(50).max(0);
58 + let offset = query.offset.unwrap_or(0).max(0);
57 59 let per_type_cap = offset.saturating_add(limit);
58 60
59 61 // Prepare search term for FTS5 (escape special characters and add prefix matching)
@@ -204,4 +204,22 @@ impl WeeklyReviewRepository for SqliteWeeklyReviewRepository {
204 204
205 205 Ok(())
206 206 }
207 +
208 + #[tracing::instrument(skip_all)]
209 + async fn list_all(&self, user_id: UserId) -> Result<Vec<WeeklyReview>> {
210 + let user_id_str = user_id.to_string();
211 +
212 + let rows: Vec<WeeklyReviewRow> = sqlx::query_as(
213 + "SELECT id, user_id, week_start_date, completed_at, notes, vacation_days
214 + FROM weekly_reviews
215 + WHERE user_id = ?
216 + ORDER BY week_start_date ASC",
217 + )
218 + .bind(&user_id_str)
219 + .fetch_all(&self.pool)
220 + .await
221 + .map_err(CoreError::database)?;
222 +
223 + rows.into_iter().map(WeeklyReview::try_from).collect()
224 + }
207 225 }
@@ -0,0 +1,156 @@
1 + //! Backup completeness + restore-ordering guards (ultra-fuzz CHRONIC-D / C1).
2 + //!
3 + //! `schema_tables_are_all_classified` is the structural enforcement: every table in
4 + //! the live schema must be either in `BACKUP_TABLES` or `EXCLUDED_TABLES`. Adding a
5 + //! migration that creates a user table without registering it fails here, which is
6 + //! what stops the recurring "backup silently omitted table X" finding.
7 + //!
8 + //! `restore_handles_recurring_task_self_fk` is the C1 regression guard: a recurring
9 + //! child instance whose `recurrence_parent_id` points at a parent that sorts later in
10 + //! the backup must restore without aborting the whole transaction.
11 +
12 + mod common;
13 +
14 + use goingson_core::backup_restore::{RestoreInput, RestoreResult};
15 + use goingson_db_sqlite::{restore_all, BACKUP_TABLES, EXCLUDED_TABLES};
16 + use sqlx::Row;
17 +
18 + fn empty_input() -> RestoreInput {
19 + RestoreInput {
20 + projects: vec![],
21 + tasks: vec![],
22 + events: vec![],
23 + emails: vec![],
24 + contacts: vec![],
25 + time_sessions: vec![],
26 + milestones: vec![],
27 + daily_notes: vec![],
28 + attachments: vec![],
29 + sync_accounts: vec![],
30 + saved_views: vec![],
31 + weekly_reviews: vec![],
32 + monthly_goals: vec![],
33 + monthly_reflections: vec![],
34 + }
35 + }
36 +
37 + #[tokio::test]
38 + async fn schema_tables_are_all_classified() {
39 + let pool = common::setup_test_db().await;
40 +
41 + let rows = sqlx::query(
42 + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
43 + )
44 + .fetch_all(&pool)
45 + .await
46 + .expect("query sqlite_master");
47 +
48 + for row in rows {
49 + let name: String = row.get("name");
50 + // FTS5 shadow/content tables (e.g. tasks_fts, tasks_fts_data) are derived
51 + // search indexes, not user content; the sqlx migration ledger is bookkeeping.
52 + if name.contains("_fts") || name == "_sqlx_migrations" {
53 + continue;
54 + }
55 + let classified =
56 + BACKUP_TABLES.contains(&name.as_str()) || EXCLUDED_TABLES.contains(&name.as_str());
57 + assert!(
58 + classified,
59 + "table `{name}` is in the schema but neither backed up (BACKUP_TABLES) nor \
60 + excluded (EXCLUDED_TABLES). Add it to one in restore.rs -- a new user table \
61 + must be a deliberate backup decision, not silently dropped."
62 + );
63 + }
64 +
65 + // And the reverse: nothing in BACKUP_TABLES should be a phantom (typo) name.
66 + for t in BACKUP_TABLES {
67 + let exists: Option<String> =
68 + sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
69 + .bind(t)
70 + .fetch_optional(&pool)
71 + .await
72 + .expect("lookup backup table");
73 + assert!(exists.is_some(), "BACKUP_TABLES lists `{t}` which is not a real table");
74 + }
75 + }
76 +
77 + #[tokio::test]
78 + async fn restore_handles_recurring_task_self_fk() {
79 + use goingson_core::{
80 + NewProject, NewTask, ProjectRepository, TaskRepository, TaskStatus,
81 + };
82 + use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository};
83 +
84 + // Build a parent + child task where the child references the parent via
85 + // recurrence_parent_id, then order them child-first in the RestoreInput so the
86 + // FK target is inserted second. Pre-fix this aborted the whole restore (FK 787).
87 + let src = common::setup_test_db().await;
88 + let user_id = common::create_test_user(&src).await;
89 + let projects = SqliteProjectRepository::new(src.clone());
90 + let tasks = SqliteTaskRepository::new(src.clone());
91 +
92 + let project = projects
93 + .create(
94 + user_id,
95 + NewProject {
96 + name: "Recurring".into(),
97 + description: String::new(),
98 + project_type: Default::default(),
99 + status: Default::default(),
100 + },
101 + )
102 + .await
103 + .unwrap();
104 +
105 + let mut parent = tasks
106 + .create(
107 + user_id,
108 + NewTask::builder("Parent occurrence").project_id(project.id).build(),
109 + )
110 + .await
111 + .unwrap();
112 + let mut child = tasks
113 + .create(
114 + user_id,
115 + NewTask::builder("Next occurrence").project_id(project.id).build(),
116 + )
117 + .await
118 + .unwrap();
119 + parent.status = TaskStatus::Completed;
120 + child.recurrence_parent_id = Some(parent.id);
121 +
122 + // Restore into a fresh DB with the child BEFORE the parent in the vec.
123 + let dst = common::setup_test_db().await;
124 + let _ = common::create_test_user(&dst).await; // satisfy users FK on restored rows
125 + // Restored rows carry the source user_id; recreate that exact user in dst.
126 + sqlx::query("INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, 'x', 'x', '2026-01-01 00:00:00')")
127 + .bind(user_id.to_string())
128 + .bind(format!("u-{user_id}@example.com"))
129 + .execute(&dst)
130 + .await
131 + .unwrap();
132 + sqlx::query("INSERT OR IGNORE INTO projects (id, user_id, name, description, project_type, status, created_at) VALUES (?, ?, 'Recurring', '', 'work', 'active', '2026-01-01 00:00:00')")
133 + .bind(project.id.to_string())
134 + .bind(user_id.to_string())
135 + .execute(&dst)
136 + .await
137 + .unwrap();
138 +
139 + let input = RestoreInput {
140 + tasks: vec![child.clone(), parent.clone()],
141 + ..empty_input()
142 + };
143 +
144 + let result: RestoreResult = restore_all(&dst, user_id, &input)
145 + .await
146 + .expect("restore must not abort on self-FK ordering (C1)");
147 + assert_eq!(result.tasks_restored, 2, "both occurrences restored");
148 +
149 + let count: i64 =
150 + sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE recurrence_parent_id = ?")
151 + .bind(parent.id.to_string())
152 + .fetch_one(&dst)
153 + .await
154 + .unwrap();
155 + assert_eq!(count, 1, "child occurrence kept its recurrence_parent_id link");
156 + }
@@ -75,6 +75,10 @@ async fn restore_preserves_completed_status_and_is_idempotent() {
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 @@ async fn restore_preserves_subtask_completion_ordering_and_annotation_timestamp(
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 @@ async fn restore_event_preserves_external_metadata() {
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 @@ async fn restore_round_trips_supplemental_collections() {
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.
@@ -0,0 +1,27 @@
1 + -- Enforce at most one running timer per user at the schema level.
2 + --
3 + -- `start_timer` checks "is a session already running?" and inserts inside a
4 + -- DEFERRED transaction whose read takes no write lock, so two concurrent calls
5 + -- could both pass the check and insert (ultra-fuzz Run #27 TOCTOU). The guarding
6 + -- index was non-unique, so nothing stopped the double insert. Make it a partial
7 + -- UNIQUE index instead.
8 +
9 + -- First, resolve any pre-existing duplicates so the unique index can be created:
10 + -- keep the most recently started running session per user, close the rest with a
11 + -- zero-length window (ended_at = started_at).
12 + UPDATE time_sessions
13 + SET ended_at = started_at
14 + WHERE ended_at IS NULL
15 + AND id NOT IN (
16 + SELECT id FROM (
17 + SELECT id,
18 + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY started_at DESC, id DESC) AS rn
19 + FROM time_sessions
20 + WHERE ended_at IS NULL
21 + )
22 + WHERE rn = 1
23 + );
24 +
25 + DROP INDEX IF EXISTS idx_time_sessions_active;
26 + CREATE UNIQUE INDEX idx_time_sessions_active
27 + ON time_sessions(user_id) WHERE ended_at IS NULL;
@@ -39,17 +39,22 @@ fn backup_filename(now: chrono::DateTime<Utc>) -> String {
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 @@ pub(crate) async fn collect_full_export(
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 @@ async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Resu
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 @@ pub async fn create_backup_now(
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
@@ -139,7 +139,7 @@ pub async fn export_json(
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 @@ pub async fn create_backup(
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 @@ pub async fn restore_backup(
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
@@ -45,7 +45,7 @@ async fn test_export_json_success() {
45 45 assert_eq!(emails.len(), 0);
46 46
47 47 // Write JSON export
48 - let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![]);
48 + let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
49 49 assert_eq!(export.total_count(), 3); // 1 project + 1 task + 1 event
50 50
51 51 let dir = tempdir().unwrap();
@@ -69,7 +69,7 @@ async fn test_export_json_empty_database() {
69 69 let events = state.events.list_all(user_id).await.unwrap();
70 70 let emails = state.emails.list_all(user_id, true).await.unwrap();
71 71
72 - let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![]);
72 + let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
73 73 assert_eq!(export.total_count(), 0);
74 74
75 75 let dir = tempdir().unwrap();
@@ -223,7 +223,7 @@ async fn test_create_backup_success() {
223 223 let events = state.events.list_all(user_id).await.unwrap();
224 224 let emails = state.emails.list_all(user_id, true).await.unwrap();
225 225
226 - let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![]);
226 + let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
227 227 assert_eq!(export.total_count(), 3);
228 228
229 229 let dir = tempdir().unwrap();
@@ -267,6 +267,85 @@ fn test_validate_export_path_accepts_normal_path() {
267 267
268 268 // ============ Export Summary ============
269 269
270 + // ============ Backup completeness (CHRONIC-D) ============
271 +
272 + #[tokio::test]
273 + async fn test_collect_full_export_includes_saved_views_and_reviews() {
274 + use chrono::NaiveDate;
275 + use goingson_core::models::{NewSavedView, ViewFilters, ViewType};
276 +
277 + let (state, user_id) = setup_test_state().await;
278 +
279 + // Seed one row in each previously-omitted collection (ultra-fuzz Run #27 SERIOUS:
280 + // backup dropped saved views, weekly/monthly reviews, vacation days).
281 + state
282 + .saved_views
283 + .create(
284 + user_id,
285 + NewSavedView {
286 + name: "Pinned tasks".into(),
287 + view_type: ViewType::Tasks,
288 + filters: ViewFilters::default(),
289 + sort_by: None,
290 + sort_order: None,
291 + is_pinned: Some(true),
292 + },
293 + )
294 + .await
295 + .unwrap();
296 +
297 + let week_start = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
298 + state.weekly_reviews.upsert(user_id, week_start, "Good week").await.unwrap();
299 + state.weekly_reviews.set_vacation_days(user_id, week_start, &[0, 4]).await.unwrap();
300 +
301 + state.monthly_reviews.upsert_goal(user_id, "2026-06", "Ship v1", 1).await.unwrap();
302 + state.monthly_reviews.upsert_reflection(user_id, "2026-06", "Highlight", "Change").await.unwrap();
303 +
304 + // The single backup-gather path must now carry all four.
305 + let export = crate::backup_scheduler::collect_full_export(&state, user_id).await.unwrap();
306 + assert_eq!(export.saved_views.len(), 1, "saved views gathered");
307 + assert_eq!(export.weekly_reviews.len(), 1, "weekly reviews gathered");
308 + assert_eq!(export.weekly_reviews[0].vacation_days, vec![0, 4], "vacation days carried");
309 + assert_eq!(export.monthly_goals.len(), 1, "monthly goals gathered");
310 + assert_eq!(export.monthly_reflections.len(), 1, "monthly reflections gathered");
311 +
312 + // And they survive a write/read serialization round-trip.
313 + let dir = tempdir().unwrap();
314 + let path = dir.path().join("backup.json.gz");
315 + backup::write_backup(&export, &path).unwrap();
316 + let restored = backup::read_backup(&path).unwrap();
317 + assert_eq!(restored.saved_views.len(), 1);
318 + assert_eq!(restored.weekly_reviews.len(), 1);
319 + assert_eq!(restored.monthly_goals.len(), 1);
320 + assert_eq!(restored.monthly_reflections.len(), 1);
321 +
322 + // And restore writes them into a fresh database (exercises the restore_* fns).
323 + let (dst, dst_user) = setup_test_state().await;
324 + let input = goingson_core::backup_restore::RestoreInput {
325 + projects: restored.projects,
326 + tasks: restored.tasks,
327 + events: restored.events,
328 + emails: restored.emails,
329 + contacts: restored.contacts,
330 + time_sessions: restored.time_sessions,
331 + milestones: restored.milestones,
332 + daily_notes: restored.daily_notes,
333 + attachments: restored.attachments,
334 + sync_accounts: restored.sync_accounts,
335 + saved_views: restored.saved_views,
336 + weekly_reviews: restored.weekly_reviews,
337 + monthly_goals: restored.monthly_goals,
338 + monthly_reflections: restored.monthly_reflections,
339 + };
340 + let result = goingson_db_sqlite::restore_all(&dst.pool, dst_user, &input).await.unwrap();
341 + assert_eq!(result.saved_views_restored, 1);
342 + assert_eq!(result.weekly_reviews_restored, 1);
343 + assert_eq!(result.monthly_goals_restored, 1);
344 + assert_eq!(result.monthly_reflections_restored, 1);
345 + assert_eq!(dst.saved_views.list_all(dst_user).await.unwrap().len(), 1);
346 + assert_eq!(dst.weekly_reviews.list_all(dst_user).await.unwrap().len(), 1);
347 + }
348 +
270 349 #[tokio::test]
271 350 async fn test_export_summary_counts() {
272 351 let (state, user_id) = setup_test_state().await;
@@ -13,8 +13,8 @@ use flate2::Compression;
13 13 use serde::{Deserialize, Serialize};
14 14
15 15 use goingson_core::{
16 - Attachment, Contact, DailyNote, Email, Event, Milestone, Project, SyncAccount, Task,
17 - TimeSession,
16 + Attachment, Contact, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection,
17 + Project, SavedView, SyncAccount, Task, TimeSession, WeeklyReview,
18 18 };
19 19
20 20 /// Full export of all GoingsOn data.
@@ -58,11 +58,23 @@ pub struct FullExport {
58 58 /// All calendar/contact sync accounts.
59 59 #[serde(default)]
60 60 pub sync_accounts: Vec<SyncAccount>,
61 + /// All saved views / filters.
62 + #[serde(default)]
63 + pub saved_views: Vec<SavedView>,
64 + /// All weekly review rows (notes + vacation days).
65 + #[serde(default)]
66 + pub weekly_reviews: Vec<WeeklyReview>,
67 + /// All monthly goals.
68 + #[serde(default)]
69 + pub monthly_goals: Vec<MonthlyGoal>,
70 + /// All monthly reflections.
71 + #[serde(default)]
72 + pub monthly_reflections: Vec<MonthlyReflection>,
61 73 }
62 74
63 75 impl FullExport {
64 76 /// Current export format version.
65 - pub const CURRENT_VERSION: &'static str = "1.2";
77 + pub const CURRENT_VERSION: &'static str = "1.3";
66 78
67 79 /// Creates a new full export with the current timestamp.
68 80 ///
@@ -80,6 +92,10 @@ impl FullExport {
80 92 daily_notes: Vec<DailyNote>,
81 93 attachments: Vec<Attachment>,
82 94 sync_accounts: Vec<SyncAccount>,
95 + saved_views: Vec<SavedView>,
96 + weekly_reviews: Vec<WeeklyReview>,
97 + monthly_goals: Vec<MonthlyGoal>,
98 + monthly_reflections: Vec<MonthlyReflection>,
83 99 ) -> Self {
84 100 Self {
85 101 version: Self::CURRENT_VERSION.to_string(),
@@ -94,6 +110,10 @@ impl FullExport {
94 110 daily_notes,
95 111 attachments,
96 112 sync_accounts,
113 + saved_views,
114 + weekly_reviews,
115 + monthly_goals,
116 + monthly_reflections,
97 117 }
98 118 }
99 119
@@ -109,6 +129,10 @@ impl FullExport {
109 129 + self.daily_notes.len()
110 130 + self.attachments.len()
111 131 + self.sync_accounts.len()
132 + + self.saved_views.len()
133 + + self.weekly_reviews.len()
134 + + self.monthly_goals.len()
135 + + self.monthly_reflections.len()
112 136 }
113 137
114 138 /// Checks if this export version is compatible with the current version.
@@ -251,22 +275,31 @@ mod tests {
251 275 use super::*;
252 276 use tempfile::tempdir;
253 277
278 + /// An empty export with every collection vec defaulted -- keeps the tests from
279 + /// re-listing the positional collection arguments.
280 + fn empty_export() -> FullExport {
281 + FullExport::new(
282 + vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![],
283 + vec![], vec![], vec![],
284 + )
285 + }
286 +
254 287 #[test]
255 288 fn test_full_export_total_count() {
256 - let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
289 + let export = empty_export();
257 290 assert_eq!(export.total_count(), 0);
258 291 }
259 292
260 293 #[test]
261 294 fn test_full_export_is_compatible() {
262 - let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
295 + let export = empty_export();
263 296 assert!(export.is_compatible());
264 297
265 - let mut old_export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
298 + let mut old_export = empty_export();
266 299 old_export.version = "1.5".to_string();
267 300 assert!(old_export.is_compatible());
268 301
269 - let mut future_export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
302 + let mut future_export = empty_export();
270 303 future_export.version = "2.0".to_string();
271 304 assert!(!future_export.is_compatible());
272 305 }
@@ -276,7 +309,7 @@ mod tests {
276 309 let dir = tempdir().unwrap();
277 310 let backup_path = dir.path().join("test.json.gz");
278 311
279 - let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
312 + let export = empty_export();
280 313 write_backup(&export, &backup_path).unwrap();
281 314
282 315 let restored = read_backup(&backup_path).unwrap();
@@ -289,7 +322,7 @@ mod tests {
289 322 let dir = tempdir().unwrap();
290 323 let json_path = dir.path().join("test.json");
291 324
292 - let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
325 + let export = empty_export();
293 326 let size = write_json(&export, &json_path).unwrap();
294 327 assert!(size > 0);
295 328