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
Signed with PGP, not checked
Commit: cc8e3edbe7d8a4d694301c3e9b01e7a27c5e11dc
Parent: 534a7f6
13 files changed, +556 insertions, -91 deletions
@@ -31,6 +31,39 @@
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 @@
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 @@
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
@@ -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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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.
@@ -10,10 +10,14 @@
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 @@
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 @@
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 @@
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 @@
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 + }
@@ -139,14 +139,7 @@
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 @@
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 @@
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
@@ -12,9 +12,19 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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
@@ -177,4 +177,19 @@
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 }
@@ -71,6 +71,22 @@
71 71 row.map(DailyNote::try_from).transpose()
72 72 }
73 73
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 +
74 90 #[tracing::instrument(skip_all)]
75 91 async fn upsert(
76 92 &self,