Skip to main content

max / goingson

feat(backup): include all syncable tables in backups and restore Auto-backup, manual backup, and JSON export only captured projects, tasks, events, emails, and contacts -- so time_sessions, milestones, daily_notes, attachments, and sync_accounts were silently absent from every backup and lost on restore (ultra-fuzz Run #26 SERIOUS finding, second half). FullExport gains those five collections (serde-defaulted, format version 1.1 -> 1.2 so older backups still load) and total_count covers them. A single collect_full_export helper is now the one place that defines backup contents, so the three backup/export call sites can't drift again. New list_all methods back the fetch: MilestoneRepository, DailyNoteRepository, AttachmentRepository, and TaskRepository::list_all_time_sessions (sync_accounts already had one). RestoreInput/RestoreResult and restore_all are extended to round-trip the five collections verbatim, and restore_all is reordered into FK-safe sequence (projects, contacts, milestones, emails, tasks, time_sessions, events, attachments, daily_notes, sync_accounts) -- which also fixes a latent bug where a task linked to a contact/email/milestone restored before its parent was silently dropped by INSERT OR IGNORE. Attachment rows carry metadata only; the content-addressed blob files are a separate store refetched by blob sync. Integration test round-trips a milestone, time session, daily note, attachment, and sync account through a wipe + restore, asserting field fidelity and idempotency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-16 00:07 UTC
Commit: cc8e3edbe7d8a4d694301c3e9b01e7a27c5e11dc
Parent: 534a7f6
13 files changed, +555 insertions, -90 deletions
@@ -6,7 +6,7 @@
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::{Email, Event, Project, Task};
9 + use crate::models::{Attachment, DailyNote, Email, Event, Milestone, Project, SyncAccount, Task, TimeSession};
10 10 use crate::Contact;
11 11
12 12 /// Result of a restore operation.
@@ -26,6 +26,16 @@ pub struct RestoreResult {
26 26 pub annotations_restored: usize,
27 27 /// Number of contacts restored.
28 28 pub contacts_restored: usize,
29 + /// Number of time sessions restored.
30 + pub time_sessions_restored: usize,
31 + /// Number of milestones restored.
32 + pub milestones_restored: usize,
33 + /// Number of daily notes restored.
34 + pub daily_notes_restored: usize,
35 + /// Number of attachment records restored.
36 + pub attachments_restored: usize,
37 + /// Number of sync accounts restored.
38 + pub sync_accounts_restored: usize,
29 39 }
30 40
31 41 /// Pre-parsed backup data for restoration.
@@ -35,6 +45,11 @@ pub struct RestoreInput {
35 45 pub events: Vec<Event>,
36 46 pub emails: Vec<Email>,
37 47 pub contacts: Vec<Contact>,
48 + pub time_sessions: Vec<TimeSession>,
49 + pub milestones: Vec<Milestone>,
50 + pub daily_notes: Vec<DailyNote>,
51 + pub attachments: Vec<Attachment>,
52 + pub sync_accounts: Vec<SyncAccount>,
38 53 }
39 54
40 55 #[cfg(test)]
@@ -58,11 +73,21 @@ mod tests {
58 73 events: vec![],
59 74 emails: vec![],
60 75 contacts: vec![],
76 + time_sessions: vec![],
77 + milestones: vec![],
78 + daily_notes: vec![],
79 + attachments: vec![],
80 + sync_accounts: vec![],
61 81 };
62 82 assert!(input.projects.is_empty());
63 83 assert!(input.tasks.is_empty());
64 84 assert!(input.events.is_empty());
65 85 assert!(input.emails.is_empty());
66 86 assert!(input.contacts.is_empty());
87 + assert!(input.time_sessions.is_empty());
88 + assert!(input.milestones.is_empty());
89 + assert!(input.daily_notes.is_empty());
90 + assert!(input.attachments.is_empty());
91 + assert!(input.sync_accounts.is_empty());
67 92 }
68 93 }
@@ -222,6 +222,9 @@ pub trait TaskRepository: Send + Sync {
222 222 /// Lists all time sessions for a task.
223 223 async fn list_time_sessions(&self, task_id: TaskId, user_id: UserId) -> Result<Vec<TimeSession>>;
224 224
225 + /// Lists every time session for a user across all tasks (for full backup export).
226 + async fn list_all_time_sessions(&self, user_id: UserId) -> Result<Vec<TimeSession>>;
227 +
225 228 /// Logs a manual time entry (retroactive, no live timer).
226 229 async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: i32, date: DateTime<Utc>) -> Result<TimeSession>;
227 230
@@ -814,6 +817,9 @@ pub trait DailyNoteRepository: Send + Sync {
814 817
815 818 /// Creates or updates a daily note (upsert by user_id + date).
816 819 async fn upsert(&self, user_id: UserId, date: NaiveDate, went_well: &str, could_improve: &str, is_reviewed: bool) -> Result<crate::models::DailyNote>;
820 +
821 + /// Lists every daily note for a user (for full backup export).
822 + async fn list_all(&self, user_id: UserId) -> Result<Vec<crate::models::DailyNote>>;
817 823 }
818 824
819 825 /// Repository for monthly review goals and reflections.
@@ -844,6 +850,9 @@ pub trait MilestoneRepository: Send + Sync {
844 850 /// Lists all milestones for a project, ordered by position.
845 851 async fn list_by_project(&self, project_id: ProjectId, user_id: UserId) -> Result<Vec<crate::models::Milestone>>;
846 852
853 + /// Lists every milestone for a user across all projects (for full backup export).
854 + async fn list_all(&self, user_id: UserId) -> Result<Vec<crate::models::Milestone>>;
855 +
847 856 /// Gets a milestone by ID.
848 857 async fn get_by_id(&self, id: MilestoneId, user_id: UserId) -> Result<Option<crate::models::Milestone>>;
849 858
@@ -998,6 +1007,9 @@ pub trait AttachmentRepository: Send + Sync {
998 1007
999 1008 /// Lists all distinct blob hashes for a user (for blob sync).
1000 1009 async fn list_all_blob_hashes(&self, user_id: UserId) -> Result<Vec<String>>;
1010 +
1011 + /// Lists every attachment record for a user (for full backup export).
1012 + async fn list_all(&self, user_id: UserId) -> Result<Vec<Attachment>>;
1001 1013 }
1002 1014
1003 1015 /// Repository for sync account CRUD operations.
@@ -177,4 +177,19 @@ impl AttachmentRepository for SqliteAttachmentRepository {
177 177
178 178 Ok(rows.into_iter().map(|(h,)| h).collect())
179 179 }
180 +
181 + #[tracing::instrument(skip_all)]
182 + async fn list_all(&self, user_id: UserId) -> Result<Vec<Attachment>> {
183 + let sql = format!(
184 + "SELECT {} FROM attachments WHERE user_id = ? ORDER BY created_at ASC",
185 + SELECT_COLS
186 + );
187 + let rows = sqlx::query_as::<_, AttachmentRow>(&sql)
188 + .bind(user_id.to_string())
189 + .fetch_all(&self.pool)
190 + .await
191 + .map_err(CoreError::database)?;
192 +
193 + rows.into_iter().map(Attachment::try_from).collect()
194 + }
180 195 }
@@ -72,6 +72,22 @@ impl DailyNoteRepository for SqliteDailyNoteRepository {
72 72 }
73 73
74 74 #[tracing::instrument(skip_all)]
75 + async fn list_all(&self, user_id: UserId) -> Result<Vec<DailyNote>> {
76 + let rows: Vec<DailyNoteRow> = sqlx::query_as(
77 + "SELECT id, user_id, note_date, went_well, could_improve, is_reviewed, reviewed_at, created_at, updated_at
78 + FROM daily_notes
79 + WHERE user_id = ?
80 + ORDER BY note_date ASC"
81 + )
82 + .bind(user_id.to_string())
83 + .fetch_all(&self.pool)
84 + .await
85 + .map_err(CoreError::database)?;
86 +
87 + rows.into_iter().map(DailyNote::try_from).collect()
88 + }
89 +
90 + #[tracing::instrument(skip_all)]
75 91 async fn upsert(
76 92 &self,
77 93 user_id: UserId,
@@ -89,6 +89,24 @@ impl MilestoneRepository for SqliteMilestoneRepository {
89 89 }
90 90
91 91 #[tracing::instrument(skip_all)]
92 + async fn list_all(&self, user_id: UserId) -> Result<Vec<Milestone>> {
93 + let rows = sqlx::query_as::<_, MilestoneRow>(
94 + r#"
95 + SELECT id, user_id, project_id, name, description, position, target_date, status, created_at
96 + FROM milestones
97 + WHERE user_id = ?
98 + ORDER BY created_at ASC
99 + "#,
100 + )
101 + .bind(user_id.to_string())
102 + .fetch_all(&self.pool)
103 + .await
104 + .map_err(CoreError::database)?;
105 +
106 + rows.into_iter().map(Milestone::try_from).collect()
107 + }
108 +
109 + #[tracing::instrument(skip_all)]
92 110 async fn get_by_id(&self, id: MilestoneId, user_id: UserId) -> Result<Option<Milestone>> {
93 111 let row = sqlx::query_as::<_, MilestoneRow>(
94 112 r#"
@@ -31,11 +31,21 @@ pub async fn restore_all(
31 31 let mut tx = pool.begin().await.map_err(CoreError::database)?;
32 32 let mut result = RestoreResult::default();
33 33
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.
34 39 restore_projects(&mut tx, user_id, input, &mut result).await?;
40 + restore_contacts_with_children(&mut tx, user_id, input, &mut result).await?;
41 + restore_milestones(&mut tx, user_id, input, &mut result).await?;
42 + restore_emails(&mut tx, user_id, input, &mut result).await?;
35 43 restore_tasks_with_children(&mut tx, user_id, input, &mut result).await?;
44 + restore_time_sessions(&mut tx, user_id, input, &mut result).await?;
36 45 restore_events(&mut tx, user_id, input, &mut result).await?;
37 - restore_emails(&mut tx, user_id, input, &mut result).await?;
38 - restore_contacts_with_children(&mut tx, user_id, input, &mut result).await?;
46 + restore_attachments(&mut tx, user_id, input, &mut result).await?;
47 + restore_daily_notes(&mut tx, user_id, input, &mut result).await?;
48 + restore_sync_accounts(&mut tx, user_id, input, &mut result).await?;
39 49
40 50 tx.commit().await.map_err(CoreError::database)?;
41 51 Ok(result)
@@ -429,3 +439,173 @@ async fn restore_contacts_with_children(
429 439 }
430 440 Ok(())
431 441 }
442 +
443 + async fn restore_milestones(
444 + tx: &mut Transaction<'_, Sqlite>,
445 + user_id: UserId,
446 + input: &RestoreInput,
447 + result: &mut RestoreResult,
448 + ) -> Result<()> {
449 + for milestone in &input.milestones {
450 + let target_date = milestone.target_date.map(|d| d.format("%Y-%m-%d").to_string());
451 + let affected = sqlx::query(
452 + "INSERT OR IGNORE INTO milestones (\
453 + id, user_id, project_id, name, description, position, target_date, status, created_at\
454 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
455 + )
456 + .bind(milestone.id.to_string())
457 + .bind(user_id.to_string())
458 + .bind(milestone.project_id.to_string())
459 + .bind(&milestone.name)
460 + .bind(&milestone.description)
461 + .bind(milestone.position)
462 + .bind(&target_date)
463 + .bind(milestone.status.db_value())
464 + .bind(format_datetime(&milestone.created_at))
465 + .execute(&mut **tx)
466 + .await
467 + .map_err(CoreError::database)?
468 + .rows_affected();
469 + if affected > 0 {
470 + result.milestones_restored += 1;
471 + }
472 + }
473 + Ok(())
474 + }
475 +
476 + async fn restore_time_sessions(
477 + tx: &mut Transaction<'_, Sqlite>,
478 + user_id: UserId,
479 + input: &RestoreInput,
480 + result: &mut RestoreResult,
481 + ) -> Result<()> {
482 + for session in &input.time_sessions {
483 + let affected = sqlx::query(
484 + "INSERT OR IGNORE INTO time_sessions (\
485 + id, task_id, user_id, started_at, ended_at, duration_minutes, created_at\
486 + ) VALUES (?, ?, ?, ?, ?, ?, ?)",
487 + )
488 + .bind(session.id.to_string())
489 + .bind(session.task_id.to_string())
490 + .bind(user_id.to_string())
491 + .bind(format_datetime(&session.started_at))
492 + .bind(format_datetime_opt(session.ended_at))
493 + .bind(session.duration_minutes)
494 + .bind(format_datetime(&session.created_at))
495 + .execute(&mut **tx)
496 + .await
497 + .map_err(CoreError::database)?
498 + .rows_affected();
499 + if affected > 0 {
500 + result.time_sessions_restored += 1;
501 + }
502 + }
503 + Ok(())
504 + }
505 +
506 + async fn restore_attachments(
507 + tx: &mut Transaction<'_, Sqlite>,
508 + user_id: UserId,
509 + input: &RestoreInput,
510 + result: &mut RestoreResult,
511 + ) -> Result<()> {
512 + // Row metadata only; the content-addressed blob files are a separate store and
513 + // are re-fetched by blob sync, not embedded in the backup.
514 + for attachment in &input.attachments {
515 + let affected = sqlx::query(
516 + "INSERT OR IGNORE INTO attachments (\
517 + id, user_id, task_id, project_id, filename, file_size, mime_type, blob_hash, \
518 + source_email_id, created_at\
519 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
520 + )
521 + .bind(attachment.id.to_string())
522 + .bind(user_id.to_string())
523 + .bind(attachment.task_id.map(|t| t.to_string()))
524 + .bind(attachment.project_id.map(|p| p.to_string()))
525 + .bind(&attachment.filename)
526 + .bind(attachment.file_size)
527 + .bind(&attachment.mime_type)
528 + .bind(&attachment.blob_hash)
529 + .bind(attachment.source_email_id.map(|e| e.to_string()))
530 + .bind(format_datetime(&attachment.created_at))
531 + .execute(&mut **tx)
532 + .await
533 + .map_err(CoreError::database)?
534 + .rows_affected();
535 + if affected > 0 {
536 + result.attachments_restored += 1;
537 + }
538 + }
539 + Ok(())
540 + }
541 +
542 + async fn restore_daily_notes(
543 + tx: &mut Transaction<'_, Sqlite>,
544 + user_id: UserId,
545 + input: &RestoreInput,
546 + result: &mut RestoreResult,
547 + ) -> Result<()> {
548 + for note in &input.daily_notes {
549 + let affected = sqlx::query(
550 + "INSERT OR IGNORE INTO daily_notes (\
551 + id, user_id, note_date, went_well, could_improve, is_reviewed, reviewed_at, \
552 + created_at, updated_at\
553 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
554 + )
555 + .bind(note.id.to_string())
556 + .bind(user_id.to_string())
557 + .bind(note.note_date.format("%Y-%m-%d").to_string())
558 + .bind(&note.went_well)
559 + .bind(&note.could_improve)
560 + .bind(if note.is_reviewed { 1 } else { 0 })
561 + .bind(format_datetime_opt(note.reviewed_at))
562 + .bind(format_datetime(&note.created_at))
563 + .bind(format_datetime(&note.updated_at))
564 + .execute(&mut **tx)
565 + .await
566 + .map_err(CoreError::database)?
567 + .rows_affected();
568 + if affected > 0 {
569 + result.daily_notes_restored += 1;
570 + }
571 + }
572 + Ok(())
573 + }
574 +
575 + async fn restore_sync_accounts(
576 + tx: &mut Transaction<'_, Sqlite>,
577 + user_id: UserId,
578 + input: &RestoreInput,
579 + result: &mut RestoreResult,
580 + ) -> Result<()> {
581 + // The sync-transient last_*_sync timestamps are intentionally omitted (they are
582 + // re-derived on the next sync), matching the synced-column set.
583 + for account in &input.sync_accounts {
584 + let calendar_ids = serde_json::to_string(&account.calendar_ids).unwrap_or_else(|_| "[]".to_string());
585 + let affected = sqlx::query(
586 + "INSERT OR IGNORE INTO sync_accounts (\
587 + id, user_id, provider, account_name, email, sync_calendars, sync_contacts, \
588 + calendar_ids, sync_interval_minutes, enabled, created_at\
589 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
590 + )
591 + .bind(account.id.to_string())
592 + .bind(user_id.to_string())
593 + .bind(&account.provider)
594 + .bind(&account.account_name)
595 + .bind(&account.email)
596 + .bind(if account.sync_calendars { 1 } else { 0 })
597 + .bind(if account.sync_contacts { 1 } else { 0 })
598 + .bind(&calendar_ids)
599 + .bind(account.sync_interval_minutes)
600 + .bind(if account.enabled { 1 } else { 0 })
601 + .bind(format_datetime(&account.created_at))
602 + .execute(&mut **tx)
603 + .await
604 + .map_err(CoreError::database)?
605 + .rows_affected();
606 + if affected > 0 {
607 + result.sync_accounts_restored += 1;
608 + }
609 + }
610 + Ok(())
611 + }
@@ -921,6 +921,11 @@ impl TaskRepository for SqliteTaskRepository {
921 921 }
922 922
923 923 #[tracing::instrument(skip_all)]
924 + async fn list_all_time_sessions(&self, user_id: UserId) -> Result<Vec<TimeSession>> {
925 + time_session_repo::list_all_time_sessions(&self.pool, user_id).await
926 + }
927 +
928 + #[tracing::instrument(skip_all)]
924 929 async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: i32, date: DateTime<Utc>) -> Result<TimeSession> {
925 930 time_session_repo::log_manual_time(&self.pool, task_id, user_id, minutes, date).await
926 931 }
@@ -275,6 +275,24 @@ pub(crate) async fn list_time_sessions(
275 275 rows.into_iter().map(|r| r.into_session()).collect()
276 276 }
277 277
278 + /// List every time session for a user across all tasks (for full backup export).
279 + pub(crate) async fn list_all_time_sessions(
280 + pool: &SqlitePool,
281 + user_id: UserId,
282 + ) -> Result<Vec<TimeSession>> {
283 + let rows = sqlx::query_as::<_, TimeSessionRow>(
284 + "SELECT id, task_id, user_id, started_at, ended_at, duration_minutes, created_at
285 + FROM time_sessions WHERE user_id = ?
286 + ORDER BY started_at ASC"
287 + )
288 + .bind(user_id.to_string())
289 + .fetch_all(pool)
290 + .await
291 + .map_err(CoreError::database)?;
292 +
293 + rows.into_iter().map(|r| r.into_session()).collect()
294 + }
295 +
278 296 /// Get aggregated time tracking summary grouped by project and date.
279 297 pub(crate) async fn get_time_summary(
280 298 pool: &SqlitePool,
@@ -10,10 +10,14 @@ mod common;
10 10
11 11 use goingson_core::backup_restore::RestoreInput;
12 12 use goingson_core::{
13 - EventRepository, NewEvent, NewProject, NewTask, ProjectRepository, TaskRepository, TaskStatus,
13 + AttachmentRepository, DailyNoteRepository, EventRepository, MilestoneRepository, NewAttachment,
14 + NewEvent, NewMilestone, NewProject, NewTask, ProjectRepository, SyncAccountRepository,
15 + TaskRepository, TaskStatus,
14 16 };
15 17 use goingson_db_sqlite::{
16 - restore_all, SqliteEventRepository, SqliteProjectRepository, SqliteTaskRepository,
18 + restore_all, SqliteAttachmentRepository, SqliteDailyNoteRepository, SqliteEventRepository,
19 + SqliteMilestoneRepository, SqliteProjectRepository, SqliteSyncAccountRepository,
20 + SqliteTaskRepository,
17 21 };
18 22
19 23 #[tokio::test]
@@ -66,6 +70,11 @@ async fn restore_preserves_completed_status_and_is_idempotent() {
66 70 events: vec![],
67 71 emails: vec![],
68 72 contacts: vec![],
73 + time_sessions: vec![],
74 + milestones: vec![],
75 + daily_notes: vec![],
76 + attachments: vec![],
77 + sync_accounts: vec![],
69 78 };
70 79
71 80 // First restore recreates with status + completed_at + id preserved.
@@ -167,6 +176,11 @@ async fn restore_preserves_subtask_completion_ordering_and_annotation_timestamp(
167 176 events: vec![],
168 177 emails: vec![],
169 178 contacts: vec![],
179 + time_sessions: vec![],
180 + milestones: vec![],
181 + daily_notes: vec![],
182 + attachments: vec![],
183 + sync_accounts: vec![],
170 184 };
171 185 let r = restore_all(&pool, user_id, &input).await.unwrap();
172 186 assert_eq!(r.tasks_restored, 1);
@@ -255,6 +269,11 @@ async fn restore_event_preserves_external_metadata() {
255 269 events: vec![full.clone()],
256 270 emails: vec![],
257 271 contacts: vec![],
272 + time_sessions: vec![],
273 + milestones: vec![],
274 + daily_notes: vec![],
275 + attachments: vec![],
276 + sync_accounts: vec![],
258 277 };
259 278 let r = restore_all(&pool, user_id, &input).await.unwrap();
260 279 assert_eq!(r.events_restored, 1);
@@ -277,3 +296,143 @@ async fn restore_event_preserves_external_metadata() {
277 296 );
278 297 assert!(restored.is_read_only, "is_read_only must survive restore");
279 298 }
299 +
300 + #[tokio::test]
301 + async fn restore_round_trips_supplemental_collections() {
302 + let pool = common::setup_test_db().await;
303 + let user = common::create_test_user(&pool).await;
304 +
305 + let projects = SqliteProjectRepository::new(pool.clone());
306 + let tasks = SqliteTaskRepository::new(pool.clone());
307 + let milestones = SqliteMilestoneRepository::new(pool.clone());
308 + let attachments = SqliteAttachmentRepository::new(pool.clone());
309 + let daily_notes = SqliteDailyNoteRepository::new(pool.clone());
310 + let sync_accounts = SqliteSyncAccountRepository::new(pool.clone());
311 +
312 + let project = projects
313 + .create(
314 + user,
315 + NewProject {
316 + name: "P".into(),
317 + description: String::new(),
318 + project_type: Default::default(),
319 + status: Default::default(),
320 + },
321 + )
322 + .await
323 + .unwrap();
324 + let task = tasks
325 + .create(user, NewTask::builder("T").build())
326 + .await
327 + .unwrap();
328 +
329 + let milestone = milestones
330 + .create(
331 + user,
332 + NewMilestone {
333 + project_id: project.id,
334 + name: "M1".into(),
335 + description: "desc".into(),
336 + position: 0,
337 + target_date: chrono::NaiveDate::from_ymd_opt(2024, 12, 31),
338 + },
339 + )
340 + .await
341 + .unwrap();
342 + let session = tasks
343 + .log_manual_time(task.id, user, 45, chrono::Utc::now())
344 + .await
345 + .unwrap();
346 + let note = daily_notes
347 + .upsert(
348 + user,
349 + chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
350 + "went well",
351 + "could improve",
352 + true,
353 + )
354 + .await
355 + .unwrap();
356 + let attachment = attachments
357 + .create(
358 + user,
359 + NewAttachment {
360 + task_id: Some(task.id),
361 + project_id: None,
362 + filename: "f.pdf".into(),
363 + file_size: 1234,
364 + mime_type: "application/pdf".into(),
365 + blob_hash: "deadbeef".into(),
366 + source_email_id: None,
367 + },
368 + )
369 + .await
370 + .unwrap();
371 + let account = sync_accounts
372 + .create(user, "google", "My Calendar", Some("me@example.com"))
373 + .await
374 + .unwrap();
375 +
376 + // The backup payload, then wipe everything (CASCADE handles the children).
377 + let input = RestoreInput {
378 + projects: vec![project.clone()],
379 + tasks: vec![tasks.get_by_id(task.id, user).await.unwrap().unwrap()],
380 + events: vec![],
381 + emails: vec![],
382 + contacts: vec![],
383 + time_sessions: vec![session.clone()],
384 + milestones: vec![milestone.clone()],
385 + daily_notes: vec![note.clone()],
386 + attachments: vec![attachment.clone()],
387 + sync_accounts: vec![account.clone()],
388 + };
389 + // Deleting tasks cascades time_sessions + attachments; deleting projects
390 + // cascades milestones; daily_notes and sync_accounts are independent.
391 + for table in ["tasks", "daily_notes", "sync_accounts", "projects"] {
392 + sqlx::query(&format!("DELETE FROM {table}"))
393 + .execute(&pool)
394 + .await
395 + .unwrap();
396 + }
397 + assert!(milestones.list_all(user).await.unwrap().is_empty());
398 + assert!(tasks.list_all_time_sessions(user).await.unwrap().is_empty());
399 + assert!(attachments.list_all(user).await.unwrap().is_empty());
400 +
401 + let r = restore_all(&pool, user, &input).await.unwrap();
402 + assert_eq!(r.milestones_restored, 1);
403 + assert_eq!(r.time_sessions_restored, 1);
404 + assert_eq!(r.daily_notes_restored, 1);
405 + assert_eq!(r.attachments_restored, 1);
406 + assert_eq!(r.sync_accounts_restored, 1);
407 +
408 + let ms = milestones.list_all(user).await.unwrap();
409 + assert_eq!(ms.len(), 1);
410 + assert_eq!(ms[0].id, milestone.id);
411 + assert_eq!(ms[0].target_date, milestone.target_date, "target_date preserved");
412 +
413 + let ts = tasks.list_all_time_sessions(user).await.unwrap();
414 + assert_eq!(ts.len(), 1);
415 + assert_eq!(ts[0].id, session.id);
416 + assert_eq!(ts[0].duration_minutes, session.duration_minutes, "minutes preserved");
417 +
418 + let dn = daily_notes.list_all(user).await.unwrap();
419 + assert_eq!(dn.len(), 1);
420 + assert!(dn[0].is_reviewed, "is_reviewed preserved");
421 +
422 + let at = attachments.list_all(user).await.unwrap();
423 + assert_eq!(at.len(), 1);
424 + assert_eq!(at[0].blob_hash, "deadbeef", "blob_hash preserved");
425 + assert_eq!(at[0].file_size, 1234, "file_size preserved");
426 +
427 + let sa = sync_accounts.list_all(user).await.unwrap();
428 + assert_eq!(sa.len(), 1);
429 + assert_eq!(sa[0].provider, "google");
430 + assert_eq!(sa[0].email.as_deref(), Some("me@example.com"));
431 +
432 + // Idempotent re-restore.
433 + let r2 = restore_all(&pool, user, &input).await.unwrap();
434 + assert_eq!(r2.milestones_restored, 0);
435 + assert_eq!(r2.time_sessions_restored, 0);
436 + assert_eq!(r2.attachments_restored, 0);
437 + assert_eq!(r2.sync_accounts_restored, 0);
438 + }
@@ -31,6 +31,39 @@ fn backup_filename(now: chrono::DateTime<Utc>) -> String {
31 31 format!("goingson-backup-{stamp}-{suffix}.json.gz")
32 32 }
33 33
34 + /// Collect every syncable collection into a `FullExport`.
35 + ///
36 + /// Single source of truth for what a backup contains, so the auto-backup, manual
37 + /// backup, and JSON-export paths cannot drift in which tables they capture (the
38 + /// drift behind the ultra-fuzz finding that auto-backup silently omitted
39 + /// time_sessions, milestones, daily_notes, attachments, and sync_accounts).
40 + pub(crate) async fn collect_full_export(
41 + state: &AppState,
42 + ) -> 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?;
53 + Ok(FullExport::new(
54 + projects,
55 + tasks,
56 + events,
57 + emails,
58 + contacts,
59 + time_sessions,
60 + milestones,
61 + daily_notes,
62 + attachments,
63 + sync_accounts,
64 + ))
65 + }
66 +
34 67 /// Starts the background backup scheduler that creates automatic backups
35 68 /// based on user settings and prunes old backups.
36 69 pub async fn start_backup_scheduler(app: tauri::AppHandle, cancel: CancellationToken) {
@@ -125,34 +158,7 @@ async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Resu
125 158 let filename = backup_filename(now);
126 159 let file_path = backup_dir.join(&filename);
127 160
128 - // Fetch all data
129 - let projects = state
130 - .projects
131 - .list_all(DESKTOP_USER_ID)
132 - .await
133 - .map_err(|e| e.to_string())?;
134 - let tasks = state
135 - .tasks
136 - .list_all(DESKTOP_USER_ID)
137 - .await
138 - .map_err(|e| e.to_string())?;
139 - let events = state
140 - .events
141 - .list_all(DESKTOP_USER_ID)
142 - .await
143 - .map_err(|e| e.to_string())?;
144 - let emails = state
145 - .emails
146 - .list_all(DESKTOP_USER_ID, true)
147 - .await
148 - .map_err(|e| e.to_string())?;
149 - let contacts = state
150 - .contacts
151 - .list_all(DESKTOP_USER_ID)
152 - .await
153 - .map_err(|e| e.to_string())?;
154 -
155 - let export = FullExport::new(projects, tasks, events, emails, contacts);
161 + let export = collect_full_export(state).await.map_err(|e| e.to_string())?;
156 162 let size = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?;
157 163
158 164 info!(
@@ -237,34 +243,7 @@ pub async fn create_backup_now(
237 243 let filename = backup_filename(now);
238 244 let file_path = backup_dir.join(&filename);
239 245
240 - // Fetch all data
241 - let projects = state
242 - .projects
243 - .list_all(DESKTOP_USER_ID)
244 - .await
245 - .map_err(|e| e.to_string())?;
246 - let tasks = state
247 - .tasks
248 - .list_all(DESKTOP_USER_ID)
249 - .await
250 - .map_err(|e| e.to_string())?;
251 - let events = state
252 - .events
253 - .list_all(DESKTOP_USER_ID)
254 - .await
255 - .map_err(|e| e.to_string())?;
256 - let emails = state
257 - .emails
258 - .list_all(DESKTOP_USER_ID, true)
259 - .await
260 - .map_err(|e| e.to_string())?;
261 - let contacts = state
262 - .contacts
263 - .list_all(DESKTOP_USER_ID)
264 - .await
265 - .map_err(|e| e.to_string())?;
266 -
267 - let export = FullExport::new(projects, tasks, events, emails, contacts);
246 + let export = collect_full_export(state).await.map_err(|e| e.to_string())?;
268 247 let item_count = export.total_count();
269 248 let size_bytes = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?;
270 249
@@ -139,14 +139,7 @@ pub async fn export_json(
139 139 ) -> Result<ExportResponse, ApiError> {
140 140 validate_export_path(&file_path)?;
141 141
142 - // Fetch all data
143 - let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
144 - let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
145 - let events = state.events.list_all(DESKTOP_USER_ID).await?;
146 - let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?;
147 - let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?;
148 -
149 - let export = backup::FullExport::new(projects, tasks, events, emails, contacts);
142 + let export = crate::backup_scheduler::collect_full_export(&state).await?;
150 143 let item_count = export.total_count();
151 144
152 145 let size_bytes = backup::write_json(&export, &file_path)
@@ -267,14 +260,7 @@ pub async fn create_backup(
267 260 let filename = format!("goingson-backup-{}.json.gz", timestamp);
268 261 let file_path = backup_dir.join(&filename);
269 262
270 - // Fetch all data
271 - let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
272 - let tasks = state.tasks.list_all(DESKTOP_USER_ID).await?;
273 - let events = state.events.list_all(DESKTOP_USER_ID).await?;
274 - let emails = state.emails.list_all(DESKTOP_USER_ID, true).await?;
275 - let contacts = state.contacts.list_all(DESKTOP_USER_ID).await?;
276 -
277 - let export = backup::FullExport::new(projects, tasks, events, emails, contacts);
263 + let export = crate::backup_scheduler::collect_full_export(&state).await?;
278 264 let item_count = export.total_count();
279 265
280 266 let size_bytes = backup::write_backup(&export, &file_path)
@@ -383,13 +369,17 @@ pub async fn restore_backup(
383 369
384 370 let backup_created_at = export.exported_at.to_rfc3339();
385 371
386 - // Delegate restore orchestration to core
387 372 let input = goingson_core::backup_restore::RestoreInput {
388 373 projects: export.projects,
389 374 tasks: export.tasks,
390 375 events: export.events,
391 376 emails: export.emails,
392 377 contacts: export.contacts,
378 + time_sessions: export.time_sessions,
379 + milestones: export.milestones,
380 + daily_notes: export.daily_notes,
381 + attachments: export.attachments,
382 + sync_accounts: export.sync_accounts,
393 383 };
394 384
395 385 // 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![]);
48 + let export = backup::FullExport::new(projects, tasks, events, emails, 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![]);
72 + let export = backup::FullExport::new(projects, tasks, events, emails, 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![]);
226 + let export = backup::FullExport::new(projects, tasks, events, emails, vec![], vec![], vec![], vec![], vec![], vec![]);
227 227 assert_eq!(export.total_count(), 3);
228 228
229 229 let dir = tempdir().unwrap();
@@ -12,9 +12,19 @@ use flate2::write::GzEncoder;
12 12 use flate2::Compression;
13 13 use serde::{Deserialize, Serialize};
14 14
15 - use goingson_core::{Contact, Email, Event, Project, Task};
15 + use goingson_core::{
16 + Attachment, Contact, DailyNote, Email, Event, Milestone, Project, SyncAccount, Task,
17 + TimeSession,
18 + };
16 19
17 20 /// Full export of all GoingsOn data.
21 + ///
22 + /// The supplemental collections (`time_sessions`, `milestones`, `daily_notes`,
23 + /// `attachments`, `sync_accounts`) are `#[serde(default)]` so backups written by
24 + /// older versions (which omitted them) still deserialize; on restore they simply
25 + /// come back empty. `attachments` carries the row metadata only -- the
26 + /// content-addressed blob files live in a separate store and are re-fetched by
27 + /// blob sync, not embedded in the JSON backup.
18 28 #[derive(Debug, Serialize, Deserialize)]
19 29 #[serde(rename_all = "camelCase")]
20 30 pub struct FullExport {
@@ -33,19 +43,43 @@ pub struct FullExport {
33 43 /// All contacts.
34 44 #[serde(default)]
35 45 pub contacts: Vec<Contact>,
46 + /// All time-tracking sessions.
47 + #[serde(default)]
48 + pub time_sessions: Vec<TimeSession>,
49 + /// All milestones.
50 + #[serde(default)]
51 + pub milestones: Vec<Milestone>,
52 + /// All daily notes.
53 + #[serde(default)]
54 + pub daily_notes: Vec<DailyNote>,
55 + /// All attachment records (blob metadata only; blob files are not embedded).
56 + #[serde(default)]
57 + pub attachments: Vec<Attachment>,
58 + /// All calendar/contact sync accounts.
59 + #[serde(default)]
60 + pub sync_accounts: Vec<SyncAccount>,
36 61 }
37 62
38 63 impl FullExport {
39 64 /// Current export format version.
40 - pub const CURRENT_VERSION: &'static str = "1.1";
65 + pub const CURRENT_VERSION: &'static str = "1.2";
41 66
42 67 /// Creates a new full export with the current timestamp.
68 + ///
69 + /// Takes every syncable collection so a backup is complete by construction;
70 + /// pass empty vecs for collections a caller does not have.
71 + #[allow(clippy::too_many_arguments)]
43 72 pub fn new(
44 73 projects: Vec<Project>,
45 74 tasks: Vec<Task>,
46 75 events: Vec<Event>,
47 76 emails: Vec<Email>,
48 77 contacts: Vec<Contact>,
78 + time_sessions: Vec<TimeSession>,
79 + milestones: Vec<Milestone>,
80 + daily_notes: Vec<DailyNote>,
81 + attachments: Vec<Attachment>,
82 + sync_accounts: Vec<SyncAccount>,
49 83 ) -> Self {
50 84 Self {
51 85 version: Self::CURRENT_VERSION.to_string(),
@@ -55,12 +89,26 @@ impl FullExport {
55 89 events,
56 90 emails,
57 91 contacts,
92 + time_sessions,
93 + milestones,
94 + daily_notes,
95 + attachments,
96 + sync_accounts,
58 97 }
59 98 }
60 99
61 100 /// Returns the total count of all items in the export.
62 101 pub fn total_count(&self) -> usize {
63 - self.projects.len() + self.tasks.len() + self.events.len() + self.emails.len() + self.contacts.len()
102 + self.projects.len()
103 + + self.tasks.len()
104 + + self.events.len()
105 + + self.emails.len()
106 + + self.contacts.len()
107 + + self.time_sessions.len()
108 + + self.milestones.len()
109 + + self.daily_notes.len()
110 + + self.attachments.len()
111 + + self.sync_accounts.len()
64 112 }
65 113
66 114 /// Checks if this export version is compatible with the current version.
@@ -205,20 +253,20 @@ mod tests {
205 253
206 254 #[test]
207 255 fn test_full_export_total_count() {
208 - let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
256 + let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
209 257 assert_eq!(export.total_count(), 0);
210 258 }
211 259
212 260 #[test]
213 261 fn test_full_export_is_compatible() {
214 - let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
262 + let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
215 263 assert!(export.is_compatible());
216 264
217 - let mut old_export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
265 + let mut old_export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
218 266 old_export.version = "1.5".to_string();
219 267 assert!(old_export.is_compatible());
220 268
221 - let mut future_export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
269 + let mut future_export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
222 270 future_export.version = "2.0".to_string();
223 271 assert!(!future_export.is_compatible());
224 272 }
@@ -228,7 +276,7 @@ mod tests {
228 276 let dir = tempdir().unwrap();
229 277 let backup_path = dir.path().join("test.json.gz");
230 278
231 - let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
279 + let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
232 280 write_backup(&export, &backup_path).unwrap();
233 281
234 282 let restored = read_backup(&backup_path).unwrap();
@@ -241,7 +289,7 @@ mod tests {
241 289 let dir = tempdir().unwrap();
242 290 let json_path = dir.path().join("test.json");
243 291
244 - let export = FullExport::new(vec![], vec![], vec![], vec![], vec![]);
292 + let export = FullExport::new(vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![], vec![]);
245 293 let size = write_json(&export, &json_path).unwrap();
246 294 assert!(size > 0);
247 295