Skip to main content

max / goingson

Fix backup-restore corruption, milestone delete, sync backoff, and time validation GO-1: backup restore reset every completed task to Pending (NewTask builder never set status/completed_at) and was non-idempotent - existence checks used the backup's IDs while create() minted new ones, so a second restore duplicated the whole dataset. Add id-preserving verbatim restore() methods to the Project/Task/Event/Email/Contact repositories (INSERT OR IGNORE, preserving UUID + status + completed_at + event/contact metadata), and rewrite restore_from_backup to use them - dropping the now-needless id remap. Restoring twice is now a no-op; completed tasks stay completed. First restore integration tests added. GO-2: milestone delete passed a callback to confirmDelete(itemType, count), whose second arg is a count - the delete never ran. Use the guard form. GO-3: sync_scheduler computed 2u64.pow(consecutive_failures) before the .min(15), overflowing at 64 consecutive failures (panic in debug, wrap to 0 -> tight retry loop in release). Clamp the exponent (.min(4)). GO-4: log_manual_time accepted negative/zero minutes, producing ended_at < started_at and decrementing the actual_minutes cache. Reject minutes < 1. Full test suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-12 20:00 UTC
Commit: 54d2ae65ca9ae2d3d758ed1b2b07d2d4694d68c2
Parent: 5f28d6c
11 files changed, +434 insertions, -120 deletions
@@ -4,16 +4,12 @@
4 4 //! for restoring data from backups. The command layer handles file I/O;
5 5 //! this module handles the restore logic using repository trait objects.
6 6
7 - use std::collections::HashMap;
8 -
9 - use crate::id_types::{ProjectId, TaskId, UserId};
7 + use crate::id_types::UserId;
10 8
11 9 use crate::error::CoreError;
12 - use crate::models::{
13 - Email, Event, NewEmail, NewEvent, NewProject, Project, Task,
14 - };
10 + use crate::models::{Email, Event, Project, Task};
15 11 use crate::{
16 - Contact, NewContact, NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle,
12 + Contact, NewContactCustomField, NewContactEmail, NewContactPhone, NewSocialHandle,
17 13 };
18 14 use crate::repository::{ContactRepository, EmailRepository, EventRepository, ProjectRepository, TaskRepository};
19 15
@@ -47,8 +43,13 @@ pub struct RestoreInput {
47 43
48 44 /// Restores entities from backup data, skipping those that already exist.
49 45 ///
50 - /// Uses existence checks (by ID or message_id) to avoid duplicates.
51 - /// This is a merge operation — existing data is preserved.
46 + /// Entities are inserted **verbatim, preserving their original UUIDs** (and
47 + /// status / completed_at / metadata) via the repositories' `restore` methods.
48 + /// Because IDs are preserved, the existence checks below find previously-restored
49 + /// rows, so restoring the same backup twice is idempotent — it does not duplicate
50 + /// the dataset. This is a merge operation: existing data is preserved, and the
51 + /// backup's foreign-key references (project_id, linked_task_id) stay valid
52 + /// without remapping.
52 53 pub async fn restore_from_backup(
53 54 user_id: UserId,
54 55 input: &RestoreInput,
@@ -60,114 +61,61 @@ pub async fn restore_from_backup(
60 61 ) -> Result<RestoreResult, CoreError> {
61 62 let mut result = RestoreResult::default();
62 63
63 - // Import projects, tracking old-to-new ID mapping
64 - let mut project_id_map: HashMap<ProjectId, ProjectId> = HashMap::new();
64 + // Projects — preserve id + created_at + status.
65 65 for project in &input.projects {
66 66 if projects.get_by_id(project.id, user_id).await?.is_none() {
67 - let new_project = NewProject {
68 - name: project.name.clone(),
69 - description: project.description.clone(),
70 - project_type: project.project_type.clone(),
71 - status: project.status.clone(),
72 - };
73 - let created = projects.create(user_id, new_project).await?;
74 - project_id_map.insert(project.id, created.id);
67 + projects.restore(user_id, project).await?;
75 68 result.projects_restored += 1;
76 69 }
77 70 }
78 71
79 - // Import tasks, remapping project_id references and restoring subtasks/annotations
80 - let mut task_id_map: HashMap<TaskId, TaskId> = HashMap::new();
72 + // Tasks — preserve id + status + completed_at + all scheduling metadata.
73 + // project_id references stay valid because project ids are preserved.
81 74 for task in &input.tasks {
82 75 if tasks.get_by_id(task.id, user_id).await?.is_none() {
83 - let new_task = crate::models::NewTask::builder(&task.description)
84 - .priority(task.priority.clone())
85 - .tags(task.tags.clone())
86 - .recurrence(task.recurrence.clone())
87 - .urgency(task.urgency);
88 -
89 - let new_task = if let Some(due) = task.due {
90 - new_task.due(due)
91 - } else {
92 - new_task
93 - };
94 -
95 - let remapped_pid = task.project_id
96 - .and_then(|pid| project_id_map.get(&pid).copied().or(Some(pid)));
97 - let new_task = if let Some(pid) = remapped_pid {
98 - new_task.project_id(pid)
99 - } else {
100 - new_task
101 - };
102 -
103 - let created = tasks.create(user_id, new_task.build()).await?;
104 - task_id_map.insert(task.id, created.id);
76 + tasks.restore(user_id, task).await?;
105 77 result.tasks_restored += 1;
106 78
107 79 // Restore annotations
108 80 for annotation in &task.annotations {
109 - if tasks.add_annotation(created.id, user_id, &annotation.note).await?.is_some() {
81 + if tasks.add_annotation(task.id, user_id, &annotation.note).await?.is_some() {
110 82 result.annotations_restored += 1;
111 83 }
112 84 }
113 85
114 86 // Restore subtasks (text-only; linked subtasks handled in second pass)
115 87 for subtask in &task.subtasks {
116 - if subtask.linked_task_id.is_none() {
117 - if tasks.add_subtask(created.id, user_id, &subtask.text).await?.is_some() {
118 - result.subtasks_restored += 1;
119 - }
88 + if subtask.linked_task_id.is_none()
89 + && tasks.add_subtask(task.id, user_id, &subtask.text).await?.is_some()
90 + {
91 + result.subtasks_restored += 1;
120 92 }
121 93 }
122 94 }
123 95 }
124 96
125 - // Second pass: restore subtasks with linked_task_id (requires all tasks to exist)
97 + // Second pass: restore subtasks with linked_task_id (requires all tasks to
98 + // exist). IDs are preserved, so parent and linked ids are used directly.
126 99 for task in &input.tasks {
127 - let new_parent_id = task_id_map.get(&task.id).copied().unwrap_or(task.id);
128 100 for subtask in &task.subtasks {
129 101 if let Some(linked_id) = subtask.linked_task_id {
130 - let new_linked_id = task_id_map.get(&linked_id).copied().unwrap_or(linked_id);
131 - if tasks.add_subtask_link(new_parent_id, user_id, new_linked_id).await.ok().flatten().is_some() {
102 + if tasks.add_subtask_link(task.id, user_id, linked_id).await.ok().flatten().is_some() {
132 103 result.subtasks_restored += 1;
133 104 }
134 105 }
135 106 }
136 107 }
137 108
138 - // Import events, remapping project_id references
109 + // Events — preserve id + full metadata (block_type, external_*, snoozed,
110 + // reminders, recurrence_parent_id).
139 111 for event in &input.events {
140 112 if events.get_by_id(event.id, user_id).await?.is_none() {
141 - let new_event = NewEvent::builder(&event.title, event.start_time)
142 - .description(&event.description)
143 - .recurrence(event.recurrence.clone());
144 -
145 - let new_event = if let Some(end) = event.end_time {
146 - new_event.end_time(end)
147 - } else {
148 - new_event
149 - };
150 -
151 - let new_event = if let Some(ref loc) = event.location {
152 - new_event.location(loc)
153 - } else {
154 - new_event
155 - };
156 -
157 - let remapped_pid = event.project_id
158 - .and_then(|pid| project_id_map.get(&pid).copied().or(Some(pid)));
159 - let new_event = if let Some(pid) = remapped_pid {
160 - new_event.project_id(pid)
161 - } else {
162 - new_event
163 - };
164 -
165 - events.create(user_id, new_event.build()).await?;
113 + events.restore(user_id, event).await?;
166 114 result.events_restored += 1;
167 115 }
168 116 }
169 117
170 - // Import emails
118 + // Emails — dedupe by message_id when present, else by preserved id.
171 119 for email in &input.emails {
172 120 let exists = if let Some(ref msg_id) = email.message_id {
173 121 emails.exists_by_message_id(user_id, msg_id).await?
@@ -176,61 +124,42 @@ pub async fn restore_from_backup(
176 124 };
177 125
178 126 if !exists {
179 - let new_email = NewEmail {
180 - project_id: email.project_id,
181 - from_address: email.from.clone(),
182 - to_address: email.to.clone(),
183 - subject: email.subject.clone(),
184 - body: email.body.clone(),
185 - is_read: email.is_read,
186 - received_at: Some(email.received_at),
187 - };
188 - emails.create(user_id, new_email).await?;
127 + emails.restore(user_id, email).await?;
189 128 result.emails_restored += 1;
190 129 }
191 130 }
192 131
193 - // Import contacts
132 + // Contacts — preserve id + created_at/updated_at; restore sub-collections.
194 133 for contact in &input.contacts {
195 134 if contacts.get_by_id(contact.id, user_id).await?.is_none() {
196 - let new_contact = NewContact {
197 - display_name: contact.display_name.clone(),
198 - nickname: contact.nickname.clone(),
199 - company: contact.company.clone(),
200 - title: contact.title.clone(),
201 - notes: contact.notes.clone(),
202 - tags: contact.tags.clone(),
203 - birthday: contact.birthday,
204 - timezone: contact.timezone.clone(),
205 - is_implicit: contact.is_implicit,
206 - };
207 - let created = contacts.create(user_id, new_contact).await?;
135 + contacts.restore(user_id, contact).await?;
208 136 result.contacts_restored += 1;
209 137
210 - // Restore sub-collections
138 + // Restore sub-collections (fresh ids; gated by the parent existence
139 + // check above so a second restore does not re-add them).
211 140 for email in &contact.emails {
212 - let _ = contacts.add_email(created.id, user_id, NewContactEmail {
141 + let _ = contacts.add_email(contact.id, user_id, NewContactEmail {
213 142 address: email.address.clone(),
214 143 label: email.label.clone(),
215 144 is_primary: email.is_primary,
216 145 }).await;
217 146 }
218 147 for phone in &contact.phones {
219 - let _ = contacts.add_phone(created.id, user_id, NewContactPhone {
148 + let _ = contacts.add_phone(contact.id, user_id, NewContactPhone {
220 149 number: phone.number.clone(),
221 150 label: phone.label.clone(),
222 151 is_primary: phone.is_primary,
223 152 }).await;
224 153 }
225 154 for handle in &contact.social_handles {
226 - let _ = contacts.add_social_handle(created.id, user_id, NewSocialHandle {
155 + let _ = contacts.add_social_handle(contact.id, user_id, NewSocialHandle {
227 156 platform: handle.platform.clone(),
228 157 handle: handle.handle.clone(),
229 158 url: handle.url.clone(),
230 159 }).await;
231 160 }
232 161 for field in &contact.custom_fields {
233 - let _ = contacts.add_custom_field(created.id, user_id, NewContactCustomField {
162 + let _ = contacts.add_custom_field(contact.id, user_id, NewContactCustomField {
234 163 label: field.label.clone(),
235 164 value: field.value.clone(),
236 165 url: field.url.clone(),
@@ -42,6 +42,11 @@ pub trait ProjectRepository: Send + Sync {
42 42 /// Creates a new project.
43 43 async fn create(&self, user_id: UserId, project: NewProject) -> Result<Project>;
44 44
45 + /// Restores a project verbatim from a backup, preserving its original ID
46 + /// and `created_at`. Idempotent (`INSERT OR IGNORE`): re-restoring the same
47 + /// backup is a no-op rather than a duplicate.
48 + async fn restore(&self, user_id: UserId, project: &Project) -> Result<()>;
49 +
45 50 /// Updates an existing project, returning `None` if not found.
46 51 async fn update(
47 52 &self,
@@ -86,6 +91,11 @@ pub trait TaskRepository: Send + Sync {
86 91 /// Creates a new task.
87 92 async fn create(&self, user_id: UserId, task: NewTask) -> Result<Task>;
88 93
94 + /// Restores a task verbatim from a backup, preserving its original ID,
95 + /// `status`, `completed_at`, and `created_at`. Idempotent (`INSERT OR
96 + /// IGNORE`). Annotations and subtasks are restored separately by the caller.
97 + async fn restore(&self, user_id: UserId, task: &Task) -> Result<()>;
98 +
89 99 /// Updates an existing task.
90 100 async fn update(&self, id: TaskId, user_id: UserId, task: UpdateTask) -> Result<Option<Task>>;
91 101
@@ -243,6 +253,12 @@ pub trait EventRepository: Send + Sync {
243 253 /// Creates a new event.
244 254 async fn create(&self, user_id: UserId, event: NewEvent) -> Result<Event>;
245 255
256 + /// Restores an event verbatim from a backup, preserving its original ID and
257 + /// all metadata the normal create path never sets — `block_type`,
258 + /// `external_source`/`external_id`, `recurrence_parent_id`, `is_read_only`,
259 + /// `snoozed_until`, and reminder offsets. Idempotent (`INSERT OR IGNORE`).
260 + async fn restore(&self, user_id: UserId, event: &Event) -> Result<()>;
261 +
246 262 /// Updates an existing event.
247 263 async fn update(&self, id: EventId, user_id: UserId, event: crate::models::UpdateEvent) -> Result<Option<Event>>;
248 264
@@ -313,6 +329,14 @@ pub trait EmailRepository: Send + Sync {
313 329 /// Creates a new email record.
314 330 async fn create(&self, user_id: UserId, email: NewEmail) -> Result<Email>;
315 331
332 + /// Restores an email from a backup, preserving its original ID and
333 + /// `message_id` so re-restore dedupes. Round-trips the durable fields
334 + /// (addresses, subject, body, html_body, read/archived/outgoing flags,
335 + /// received_at, threading, labels); transient IMAP-sync and draft-compose
336 + /// state is not restored (emails re-sync from the server). Idempotent
337 + /// (`INSERT OR IGNORE`).
338 + async fn restore(&self, user_id: UserId, email: &Email) -> Result<()>;
339 +
316 340 /// Creates an email with follow-up tracking fields.
317 341 async fn create_with_tracking(&self, user_id: UserId, email: NewEmailWithTracking) -> Result<Email>;
318 342
@@ -871,6 +895,12 @@ pub trait ContactRepository: Send + Sync {
871 895 /// Creates a new contact.
872 896 async fn create(&self, user_id: UserId, contact: NewContact) -> Result<Contact>;
873 897
898 + /// Restores a contact verbatim from a backup, preserving its original ID,
899 + /// `created_at`, and `updated_at`. Idempotent (`INSERT OR IGNORE`).
900 + /// Sub-collections (emails, phones, social handles, custom fields) are
901 + /// restored separately by the caller.
902 + async fn restore(&self, user_id: UserId, contact: &Contact) -> Result<()>;
903 +
874 904 /// Updates an existing contact, returning `None` if not found.
875 905 async fn update(&self, id: ContactId, user_id: UserId, contact: UpdateContact) -> Result<Option<Contact>>;
876 906
@@ -13,7 +13,7 @@ use goingson_core::{
13 13 SocialHandle, SocialHandleId, UpdateContact, UserId,
14 14 };
15 15
16 - use crate::utils::{escape_like, format_datetime_now, parse_datetime, parse_tags, parse_uuid};
16 + use crate::utils::{escape_like, format_datetime, format_datetime_now, parse_datetime, parse_tags, parse_uuid};
17 17
18 18 // ============ Row Structs ============
19 19
@@ -390,6 +390,38 @@ impl ContactRepository for SqliteContactRepository {
390 390 }
391 391
392 392 #[tracing::instrument(skip_all)]
393 + async fn restore(&self, user_id: UserId, contact: &Contact) -> Result<()> {
394 + let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string());
395 + let birthday_str = contact.birthday.map(|d| d.format("%Y-%m-%d").to_string());
396 +
397 + sqlx::query(
398 + r#"
399 + INSERT OR IGNORE INTO contacts (id, user_id, display_name, nickname, company, title, notes, tags, birthday, timezone, external_source, external_id, is_implicit, created_at, updated_at)
400 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
401 + "#,
402 + )
403 + .bind(contact.id.to_string())
404 + .bind(user_id.to_string())
405 + .bind(&contact.display_name)
406 + .bind(&contact.nickname)
407 + .bind(&contact.company)
408 + .bind(&contact.title)
409 + .bind(&contact.notes)
410 + .bind(&tags_json)
411 + .bind(&birthday_str)
412 + .bind(&contact.timezone)
413 + .bind(&contact.external_source)
414 + .bind(&contact.external_id)
415 + .bind(if contact.is_implicit { 1 } else { 0 })
416 + .bind(format_datetime(&contact.created_at))
417 + .bind(format_datetime(&contact.updated_at))
418 + .execute(&self.pool)
419 + .await
420 + .map_err(CoreError::database)?;
421 + Ok(())
422 + }
423 +
424 + #[tracing::instrument(skip_all)]
393 425 async fn update(&self, id: ContactId, user_id: UserId, contact: UpdateContact) -> Result<Option<Contact>> {
394 426 let now = format_datetime_now();
395 427 let tags_json = serde_json::to_string(&contact.tags).unwrap_or_else(|_| "[]".to_string());
@@ -297,6 +297,47 @@ impl EmailRepository for SqliteEmailRepository {
297 297 }
298 298
299 299 #[tracing::instrument(skip_all)]
300 + async fn restore(&self, user_id: UserId, email: &Email) -> Result<()> {
301 + // Durable content fields are round-tripped; account-linked and
302 + // sync-transient state (email_account_id, imap_uid, source_folder,
303 + // attachment_meta, draft_account_id) is intentionally omitted — those
304 + // FK into email_accounts (not part of a backup) or are re-derived on the
305 + // next IMAP sync. Preserving the original id + message_id makes a second
306 + // restore a no-op.
307 + let labels_json = serde_json::to_string(&email.labels).unwrap_or_else(|_| "[]".to_string());
308 + sqlx::query(
309 + "INSERT OR IGNORE INTO emails (id, user_id, project_id, from_address, to_address, subject, body, html_body, is_read, is_archived, received_at, message_id, in_reply_to, thread_id, is_outgoing, labels, is_draft, cc_address, bcc_address, snoozed_until, waiting_for_response, waiting_since, expected_response_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
310 + )
311 + .bind(email.id.to_string())
312 + .bind(user_id.to_string())
313 + .bind(email.project_id.map(|p| p.to_string()))
314 + .bind(&email.from)
315 + .bind(&email.to)
316 + .bind(&email.subject)
317 + .bind(&email.body)
318 + .bind(&email.html_body)
319 + .bind(if email.is_read { 1 } else { 0 })
320 + .bind(if email.is_archived { 1 } else { 0 })
321 + .bind(format_datetime(&email.received_at))
322 + .bind(&email.message_id)
323 + .bind(&email.in_reply_to)
324 + .bind(&email.thread_id)
325 + .bind(if email.is_outgoing { 1 } else { 0 })
326 + .bind(&labels_json)
327 + .bind(if email.is_draft { 1 } else { 0 })
328 + .bind(&email.cc_address)
329 + .bind(&email.bcc_address)
330 + .bind(format_datetime_opt(email.snoozed_until))
331 + .bind(if email.waiting_for_response { 1 } else { 0 })
332 + .bind(format_datetime_opt(email.waiting_since))
333 + .bind(format_datetime_opt(email.expected_response_date))
334 + .execute(&self.pool)
335 + .await
336 + .map_err(CoreError::database)?;
337 + Ok(())
338 + }
339 +
340 + #[tracing::instrument(skip_all)]
300 341 async fn create_with_tracking(&self, user_id: UserId, email: NewEmailWithTracking) -> Result<Email> {
301 342 let id = goingson_core::deterministic_email_id(email.message_id.as_deref());
302 343 let received_at = format_datetime(&email.received_at.unwrap_or_else(Utc::now));
@@ -205,6 +205,44 @@ impl EventRepository for SqliteEventRepository {
205 205 }
206 206
207 207 #[tracing::instrument(skip_all)]
208 + async fn restore(&self, user_id: UserId, event: &Event) -> Result<()> {
209 + let recurrence_rule_json = event.recurrence_rule.as_ref()
210 + .map(|r| serde_json::to_string(r).unwrap_or_default());
211 + let reminder_offsets_json = if event.reminder_offsets_seconds.is_empty() {
212 + None
213 + } else {
214 + Some(serde_json::to_string(&event.reminder_offsets_seconds).unwrap_or_default())
215 + };
216 +
217 + sqlx::query(
218 + "INSERT OR IGNORE INTO events (id, user_id, project_id, title, description, start_time, end_time, location, linked_task_id, recurrence, recurrence_rule, recurrence_parent_id, contact_id, block_type, external_source, external_id, is_read_only, snoozed_until, reminder_offsets_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
219 + )
220 + .bind(event.id.to_string())
221 + .bind(user_id.to_string())
222 + .bind(event.project_id.map(|p| p.to_string()))
223 + .bind(&event.title)
224 + .bind(&event.description)
225 + .bind(format_datetime(&event.start_time))
226 + .bind(format_datetime_opt(event.end_time))
227 + .bind(&event.location)
228 + .bind(event.linked_task_id.map(|t| t.to_string()))
229 + .bind(event.recurrence.db_value())
230 + .bind(&recurrence_rule_json)
231 + .bind(event.recurrence_parent_id.map(|p| p.to_string()))
232 + .bind(event.contact_id.map(|c| c.to_string()))
233 + .bind(event.block_type.as_ref().map(|b| b.db_value()))
234 + .bind(&event.external_source)
235 + .bind(&event.external_id)
236 + .bind(if event.is_read_only { 1 } else { 0 })
237 + .bind(format_datetime_opt(event.snoozed_until))
238 + .bind(&reminder_offsets_json)
239 + .execute(&self.pool)
240 + .await
241 + .map_err(CoreError::database)?;
242 + Ok(())
243 + }
244 +
245 + #[tracing::instrument(skip_all)]
208 246 async fn update(&self, id: EventId, user_id: UserId, event: UpdateEvent) -> Result<Option<Event>> {
209 247 let start_str = format_datetime(&event.start_time);
210 248 let end_str = format_datetime_opt(event.end_time);
@@ -11,7 +11,7 @@ use goingson_core::{
11 11 ProjectStatus, ProjectType, Result, UpdateProject, UserId,
12 12 };
13 13
14 - use crate::utils::{format_datetime_now, parse_datetime, parse_uuid};
14 + use crate::utils::{format_datetime, format_datetime_now, parse_datetime, parse_uuid};
15 15
16 16 /// Database row struct for Project
17 17 #[derive(Debug, Clone, sqlx::FromRow)]
@@ -122,6 +122,27 @@ impl ProjectRepository for SqliteProjectRepository {
122 122 }
123 123
124 124 #[tracing::instrument(skip_all)]
125 + async fn restore(&self, user_id: UserId, project: &Project) -> Result<()> {
126 + sqlx::query(
127 + r#"
128 + INSERT OR IGNORE INTO projects (id, user_id, name, description, project_type, status, created_at)
129 + VALUES (?, ?, ?, ?, ?, ?, ?)
130 + "#,
131 + )
132 + .bind(project.id.to_string())
133 + .bind(user_id.to_string())
134 + .bind(&project.name)
135 + .bind(&project.description)
136 + .bind(project.project_type.db_value())
137 + .bind(project.status.db_value())
138 + .bind(format_datetime(&project.created_at))
139 + .execute(&self.pool)
140 + .await
141 + .map_err(CoreError::database)?;
142 + Ok(())
143 + }
144 +
145 + #[tracing::instrument(skip_all)]
125 146 async fn update(
126 147 &self,
127 148 id: ProjectId,
@@ -487,6 +487,56 @@ impl TaskRepository for SqliteTaskRepository {
487 487 }
488 488
489 489 #[tracing::instrument(skip_all)]
490 + async fn restore(&self, user_id: UserId, task: &Task) -> Result<()> {
491 + let tags_json = serde_json::to_string(&task.tags).unwrap_or_else(|_| "[]".to_string());
492 + let recurrence_rule_json = task.recurrence_rule.as_ref()
493 + .map(|r| serde_json::to_string(r).unwrap_or_default());
494 +
495 + sqlx::query(
496 + r#"
497 + INSERT OR IGNORE INTO tasks (
498 + id, user_id, project_id, contact_id, milestone_id, description, status,
499 + priority, due, tags, urgency, recurrence, recurrence_rule, recurrence_parent_id,
500 + source_email_id, snoozed_until, waiting_for_response, waiting_since, expected_response_date,
501 + scheduled_start, scheduled_duration, estimated_minutes, actual_minutes,
502 + created_at, completed_at, is_focus, focus_set_at
503 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
504 + "#,
505 + )
506 + .bind(task.id.to_string())
507 + .bind(user_id.to_string())
508 + .bind(task.project_id.map(|p| p.to_string()))
509 + .bind(task.contact_id.map(|c| c.to_string()))
510 + .bind(task.milestone_id.map(|m| m.to_string()))
511 + .bind(&task.description)
512 + .bind(task.status.db_value())
513 + .bind(task.priority.db_value())
514 + .bind(format_datetime_opt(task.due))
515 + .bind(&tags_json)
516 + .bind(task.urgency)
517 + .bind(task.recurrence.db_value())
518 + .bind(&recurrence_rule_json)
519 + .bind(task.recurrence_parent_id.map(|p| p.to_string()))
520 + .bind(task.source_email_id.map(|e| e.to_string()))
521 + .bind(format_datetime_opt(task.snoozed_until))
522 + .bind(if task.waiting_for_response { 1 } else { 0 })
523 + .bind(format_datetime_opt(task.waiting_since))
524 + .bind(format_datetime_opt(task.expected_response_date))
525 + .bind(format_datetime_opt(task.scheduled_start))
526 + .bind(task.scheduled_duration)
527 + .bind(task.estimated_minutes)
528 + .bind(task.actual_minutes)
529 + .bind(format_datetime(&task.created_at))
530 + .bind(task.completed_at.map(|d| format_datetime(&d)))
531 + .bind(if task.is_focus { 1 } else { 0 })
532 + .bind(task.focus_set_at.map(|d| format_datetime(&d)))
533 + .execute(&self.pool)
534 + .await
535 + .map_err(CoreError::database)?;
536 + Ok(())
537 + }
538 +
539 + #[tracing::instrument(skip_all)]
490 540 async fn update(&self, id: TaskId, user_id: UserId, task: UpdateTask) -> Result<Option<Task>> {
491 541 let due_str = format_datetime_opt(task.due);
492 542 let scheduled_start_str = format_datetime_opt(task.scheduled_start);
@@ -0,0 +1,163 @@
1 + //! Integration tests for backup restore (GO-1 launch fixes).
2 + //!
3 + //! Covers: completed-status + completed_at preservation, event-metadata
4 + //! round-trip (the fields the normal create path never sets), and idempotent
5 + //! re-restore (restoring the same backup twice must not duplicate the dataset).
6 +
7 + mod common;
8 +
9 + use goingson_core::backup_restore::{restore_from_backup, RestoreInput};
10 + use goingson_core::{
11 + EventRepository, NewEvent, NewProject, NewTask, ProjectRepository, TaskRepository, TaskStatus,
12 + };
13 + use goingson_db_sqlite::{
14 + SqliteContactRepository, SqliteEmailRepository, SqliteEventRepository, SqliteProjectRepository,
15 + SqliteTaskRepository,
16 + };
17 +
18 + #[tokio::test]
19 + async fn restore_preserves_completed_status_and_is_idempotent() {
20 + let pool = common::setup_test_db().await;
21 + let user_id = common::create_test_user(&pool).await;
22 +
23 + let projects = SqliteProjectRepository::new(pool.clone());
24 + let tasks = SqliteTaskRepository::new(pool.clone());
25 + let events = SqliteEventRepository::new(pool.clone());
26 + let emails = SqliteEmailRepository::new(pool.clone());
27 + let contacts = SqliteContactRepository::new(pool.clone());
28 +
29 + // Manufacture a project and a COMPLETED task.
30 + let project = projects
31 + .create(
32 + user_id,
33 + NewProject {
34 + name: "Backup Project".into(),
35 + description: String::new(),
36 + project_type: Default::default(),
37 + status: Default::default(),
38 + },
39 + )
40 + .await
41 + .unwrap();
42 +
43 + let task = tasks
44 + .create(user_id, NewTask::builder("Finish the report").build())
45 + .await
46 + .unwrap();
47 + tasks.complete(task.id, user_id).await.unwrap();
48 + let completed = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap();
49 + assert_eq!(completed.status, TaskStatus::Completed);
50 + assert!(completed.completed_at.is_some());
51 +
52 + // Simulate data loss: hard-delete the rows.
53 + sqlx::query("DELETE FROM tasks WHERE id = ?")
54 + .bind(task.id.to_string())
55 + .execute(&pool)
56 + .await
57 + .unwrap();
58 + sqlx::query("DELETE FROM projects WHERE id = ?")
59 + .bind(project.id.to_string())
60 + .execute(&pool)
61 + .await
62 + .unwrap();
63 + assert!(tasks.get_by_id(task.id, user_id).await.unwrap().is_none());
64 +
65 + let input = RestoreInput {
66 + projects: vec![project.clone()],
67 + tasks: vec![completed.clone()],
68 + events: vec![],
69 + emails: vec![],
70 + contacts: vec![],
71 + };
72 +
73 + // First restore recreates with status + completed_at + id preserved.
74 + let r1 = restore_from_backup(user_id, &input, &projects, &tasks, &events, &emails, &contacts)
75 + .await
76 + .unwrap();
77 + assert_eq!(r1.projects_restored, 1);
78 + assert_eq!(r1.tasks_restored, 1);
79 + let restored = tasks
80 + .get_by_id(task.id, user_id)
81 + .await
82 + .unwrap()
83 + .expect("task restored");
84 + assert_eq!(
85 + restored.status,
86 + TaskStatus::Completed,
87 + "completed status must survive restore"
88 + );
89 + assert!(
90 + restored.completed_at.is_some(),
91 + "completed_at must survive restore"
92 + );
93 + assert_eq!(restored.id, task.id, "original id preserved");
94 +
95 + // Second restore is a no-op — no duplication.
96 + let r2 = restore_from_backup(user_id, &input, &projects, &tasks, &events, &emails, &contacts)
97 + .await
98 + .unwrap();
99 + assert_eq!(r2.projects_restored, 0);
100 + assert_eq!(r2.tasks_restored, 0);
101 + assert_eq!(
102 + tasks.list_all(user_id).await.unwrap().len(),
103 + 1,
104 + "no duplicate task after re-restore"
105 + );
106 + assert_eq!(
107 + projects.list_all(user_id).await.unwrap().len(),
108 + 1,
109 + "no duplicate project after re-restore"
110 + );
111 + }
112 +
113 + #[tokio::test]
114 + async fn restore_event_preserves_external_metadata() {
115 + let pool = common::setup_test_db().await;
116 + let user_id = common::create_test_user(&pool).await;
117 + let events = SqliteEventRepository::new(pool.clone());
118 +
119 + // Manufacture an event, then stamp external-sync metadata that the normal
120 + // create path never writes (external_source/external_id/is_read_only).
121 + let ev = events
122 + .create(
123 + user_id,
124 + NewEvent::builder("Sync Meeting", chrono::Utc::now()).build(),
125 + )
126 + .await
127 + .unwrap();
128 + sqlx::query("UPDATE events SET external_source = ?, external_id = ?, is_read_only = 1 WHERE id = ?")
129 + .bind("google")
130 + .bind("evt-123")
131 + .bind(ev.id.to_string())
132 + .execute(&pool)
133 + .await
134 + .unwrap();
135 + let full = events.get_by_id(ev.id, user_id).await.unwrap().unwrap();
136 + assert_eq!(full.external_source.as_deref(), Some("google"));
137 +
138 + // Lose it, then restore verbatim.
139 + sqlx::query("DELETE FROM events WHERE id = ?")
140 + .bind(ev.id.to_string())
141 + .execute(&pool)
142 + .await
143 + .unwrap();
144 + events.restore(user_id, &full).await.unwrap();
145 +
146 + let restored = events
147 + .get_by_id(ev.id, user_id)
148 + .await
149 + .unwrap()
150 + .expect("event restored");
151 + assert_eq!(restored.id, ev.id);
152 + assert_eq!(
153 + restored.external_source.as_deref(),
154 + Some("google"),
155 + "external_source must survive restore"
156 + );
157 + assert_eq!(
158 + restored.external_id.as_deref(),
159 + Some("evt-123"),
160 + "external_id must survive restore"
161 + );
162 + assert!(restored.is_read_only, "is_read_only must survive restore");
163 + }
@@ -498,16 +498,15 @@
498 498
499 499 async function deleteMilestone(id) {
500 500 const projectId = GoingsOn.state.currentProjectId;
501 - await GoingsOn.ui.confirmDelete('milestone', async () => {
502 - await GoingsOn.ui.apiCall(
503 - GoingsOn.api.milestones.delete(id),
504 - {
505 - successMessage: 'Milestone deleted',
506 - errorMessage: 'Failed to delete milestone',
507 - reload: () => loadDashboard(projectId),
508 - }
509 - );
510 - });
501 + if (!await GoingsOn.ui.confirmDelete('milestone')) return;
502 + await GoingsOn.ui.apiCall(
503 + GoingsOn.api.milestones.delete(id),
504 + {
505 + successMessage: 'Milestone deleted',
506 + errorMessage: 'Failed to delete milestone',
507 + reload: () => loadDashboard(projectId),
508 + }
509 + );
511 510 }
512 511
513 512 // ============ Populate GoingsOn.projects Namespace ============
@@ -112,6 +112,12 @@ pub async fn log_manual_time(
112 112 state: State<'_, Arc<AppState>>,
113 113 input: LogManualTimeInput,
114 114 ) -> Result<TimeSession, ApiError> {
115 + // Reject non-positive durations: a negative value produces ended_at <
116 + // started_at and decrements the task's actual_minutes cache (can go
117 + // negative); zero records an empty session.
118 + if input.minutes < 1 {
119 + return Err(ApiError::validation("minutes", "Minutes must be at least 1"));
120 + }
115 121 Ok(state.tasks.log_manual_time(input.task_id, DESKTOP_USER_ID, input.minutes, input.date).await?)
116 122 }
117 123
@@ -204,7 +204,12 @@ pub async fn start_sync_scheduler(app: tauri::AppHandle, cancel: CancellationTok
204 204 backoff_until = Some(chrono::Utc::now() + chrono::Duration::hours(1));
205 205 } else {
206 206 consecutive_failures += 1;
207 - let backoff_minutes = std::cmp::min(2u64.pow(consecutive_failures), 15);
207 + // Clamp the exponent before pow: an unbounded counter (failures
208 + // only reset on success) would overflow u64 at 64 consecutive
209 + // failures — panic in debug, wrap-to-0 (tight retry loop) in
210 + // release. min(4) caps backoff at 16m before the 15m clamp,
211 + // matching email_sync_scheduler.
212 + let backoff_minutes = std::cmp::min(2u64.pow(consecutive_failures.min(4)), 15);
208 213 backoff_until = Some(chrono::Utc::now() + chrono::Duration::minutes(backoff_minutes as i64));
209 214 let _ = app.emit("sync:status-changed", "error");
210 215 warn!("Auto-sync failed (attempt {}, backoff {}m): {}", consecutive_failures, backoff_minutes, e);